From 73915b67cbb57cce26235d99210f46d8fa8d99bf Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 29 Apr 2026 10:37:18 +0200 Subject: [PATCH] Prepare RTLPlus for new streaming prov --- .../providers/rtlplus/vod_manager.py | 1280 ++++++----------- 1 file changed, 440 insertions(+), 840 deletions(-) diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index 8017480..1170568 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -2,956 +2,556 @@ """ RTL+ VOD Manager -Implements get_vod_category(path) for RTLPlusProvider. +Handles Video on Demand content using the Bedrock layout API. +Follows the same patterns as channel_manager and event_manager. -Tree structure --------------- -TopicWorlds branch (series/shows by topic) - [] → root: TopicWorld VodCategories - + Movies root + Series root - [""] → Format/Movie items within a TopicWorld - ["", ""] → Season VodCategories - ["", "", ""] → Episode VodItems - -Movies branch (OverviewPage, elementType=MOVIE) - ["movies-genre:/video-tv/filme"] → Genre VodCategories - ["movies-genre:/video-tv/filme", - "movies-genre:/video-tv/filme/genre/action"] → Movie VodItems (paginated) - -Series branch (OverviewPage, elementType=SERIES) - ["series-genre:/video-tv/serien"] → Genre VodCategories - ["series-genre:/video-tv/serien", - "series-genre:/video-tv/serien/genre/drama"] → Series VodItems (paginated) - -content_id conventions ------------------------ -- TopicWorlds: rrn:multipurpose:internal:page:topic-world (or similar) → VodCategory -- Formats: rrn:watch:videohub:format:{id} → VodCategory -- Season key: "season:{discriminator}@{format_rrn}" (synthetic) → VodCategory - discriminator = ordinal string for ORDINAL, YYYYMM for ANNUAL -- Episodes: rrn:watch:videohub:episode:{id} → VodItem -- Movies: rrn:watch:videohub:movie:{id} → VodItem -- Movie root: "/video-tv/filme" → VodCategory -- Movie genre: "/video-tv/filme/genre/" → VodCategory -- Series root: "/video-tv/serien" → VodCategory -- Series genre: "/video-tv/serien/genre/" → VodCategory - -Stream resolution ------------------ -provider.get_manifest(content_id) / provider.get_drm(content_id) handle VOD -RRNs via WatchPlayerConfigV3 (GraphQL) and the Wurstland config endpoint. -This manager never performs stream or DRM fetches directly. - -GraphQL note -------------- -RTL+ uses persisted queries exclusively. All operation names and hashes live -in RTLPlusGraphQL so this file contains zero magic strings. -Partial-data (valueCompletion) responses are handled defensively throughout. +Manifest and DRM resolution is always delegated to the central +provider.get_manifest() / provider.get_drm() methods, which own +the upfront-token flow and all format/quality selection logic. """ -import calendar -from typing import List, Optional, Union +from typing import Dict, Any, List, Optional, Union +import re from ...base.models.vod import VodCategory, VodItem +from ...base.models import DRMConfig from ...base.utils.logger import logger -from .constants import RTLPlusConfig, RTLPlusDefaults, RTLPlusGraphQL +from .constants import RTLPlusDefaults class RTLPlusVodManager: """ - Handles VOD browsing for RTL+. + Manages VOD content for RTL+. - Instantiated and owned by RTLPlusProvider. Requires a reference to the - provider so it can reuse _get_rtlplus_authenticated_headers() and - http_manager. + Navigation hierarchy: + root + └── folder: (Bedrock folder, e.g. folder:3 = Serien) + └── program: (program layout → seasons) + └── season: (block layout → episodes) + └── clip_id (video layout, playable) + + Manifest and DRM for playable items are always resolved through + the provider's central get_manifest() / get_drm() methods. """ def __init__(self, provider): self._provider = provider # ------------------------------------------------------------------ - # Convenience shorthands + # Convenience accessors # ------------------------------------------------------------------ @property - def _cfg(self) -> RTLPlusConfig: + def cfg(self): return self._provider.rtl_config @property - def _graphql_url(self) -> str: - return self._cfg.graphql_endpoint + def http(self): + return self._provider.http_manager - def _headers(self) -> dict: - return self._provider._get_rtlplus_authenticated_headers() + @property + def auth(self): + return self._provider.authenticator # ------------------------------------------------------------------ - # Public entry point — called by provider.get_vod_category() + # Public API (VodOperations interface) # ------------------------------------------------------------------ def get_vod_category( - self, content_id: str = "", **kwargs - ) -> List[Union[VodCategory, VodItem]]: + self, + content_id: str = "", + cursor: Optional[str] = None, + page_size: int = 24, + **kwargs, + ) -> Dict[str, Any]: """ - Return the children of the VOD node identified by *content_id*. + Return the children of a VOD node. - content_id is a single opaque token — never split by the caller. - Dispatch is by prefix: + content_id conventions + ---------------------- + "" → root (fetched from home layout) + "folder:" → Bedrock folder (e.g. "folder:3") + "program:" → program/series (e.g. "program:10438") + "season:" → season block (e.g. "season:abc-123") - "" → root - "/video-tv/filme" → movies branch - "/video-tv/serien" → series branch - "/video-tv/shows" → shows branch - "rrn:watch:videohub:format:*" → list seasons for format - "rrn:watch:videohub:season:*" → list episodes for season - "rrn:multipurpose:*" → list formats for topic world - "movies-genre:*" → movie genre items - "series-genre:*" → series genre items - "season:*" → episodes for season key + Returns + ------- + { + "entries": List[VodCategory | VodItem], + "next_cursor": Optional[str], + "total": Optional[int], + } """ if not content_id: - return self._list_root() + return self._get_root_category() - # Restore leading slash stripped by Bottle's :path wildcard for - # watch-path style IDs (e.g. "video-tv/filme" → "/video-tv/filme"). - # Opaque IDs (rrn:, lane:, season: …) never start with "/" anyway. - if "/" in content_id and not content_id.startswith("/") and ":" not in content_id: - content_id = "/" + content_id + if content_id.startswith("folder:"): + folder_id = content_id[len("folder:"):] + return self._get_folder_contents(folder_id, cursor, page_size) - if content_id.startswith(RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH): - return self._dispatch_movies([content_id]) - - if content_id.startswith(RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH): - return self._dispatch_series([content_id]) - - if content_id.startswith(RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH): - return self._dispatch_shows([content_id]) - - if content_id.startswith("rrn:watch:videohub:format:"): - return self._list_seasons(content_id) - - if content_id.startswith("rrn:watch:videohub:season:"): - return self._list_episodes_for_season(content_id) - - if content_id.startswith("rrn:"): - return self._list_formats_for_topic(content_id) - - if content_id.startswith("movies-genre:") or content_id.startswith("series-genre:"): - return self._dispatch_movies([content_id]) if content_id.startswith("movies-genre:") else self._dispatch_series([content_id]) + if content_id.startswith("program:"): + program_id = content_id[len("program:"):] + return self._get_program_seasons(program_id, cursor, page_size) if content_id.startswith("season:"): - # season:/ — encoded as a single token - # e.g. "season:rrn:watch:videohub:format:123/s1" - rest = content_id[len("season:"):] - if "/" in rest: - format_rrn, season_key = rest.split("/", 1) - return self._list_episodes(format_rrn, season_key) + season_id = content_id[len("season:"):] + return self._get_season_episodes(season_id, cursor, page_size) - logger.warning(f"RTLPlusVodManager: unrecognised content_id: {content_id!r}") - return [] + # Bare numeric ID → treat as program + if content_id.isdigit(): + return self._get_program_seasons(content_id, cursor, page_size) - # ------------------------------------------------------------------ - # Depth 0 — root - # ------------------------------------------------------------------ + logger.warning(f"Unrecognised VOD content_id format: {content_id!r}") + return {"entries": [], "next_cursor": None, "total": 0} - def _list_root(self) -> List[Union[VodCategory, VodItem]]: + def get_vod_item_info(self, clip_id: str) -> Optional[VodItem]: """ - Return the top-level VOD nodes: - 1. Synthetic "Filme" VodCategory → gates the movies-by-genre branch. - 2. Synthetic "Serien" VodCategory → gates the series-by-genre branch. - 3. All TopicWorld VodCategories → shows/sports/etc. by topic. + Return metadata for a single playable clip. + Does NOT fetch manifest/DRM — callers use get_manifest/get_drm for that. """ - results: List[Union[VodCategory, VodItem]] = [ - VodCategory( - name="Filme", - content_id=RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH, - provider="rtlplus", - description="Filme & Dokumentationen auf RTL+", - ), - VodCategory( - name="Serien", - content_id=RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH, - 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") - return results + layout = self._provider.fetch_layout( + layout_type="video", + content_id=clip_id, + location=f"{self.cfg.beta_website}{clip_id}", + ) + if not layout: + return None + return self._extract_vod_item_from_layout(layout, clip_id) + + def get_manifest_for_video(self, clip_id: str) -> Optional[str]: + """ + Resolve the playback manifest URL for a VOD clip. + + Delegates entirely to provider.get_manifest(), which owns + format/quality selection and redirect resolution. + """ + return self._provider.get_manifest(clip_id) + + def get_drm_for_video(self, clip_id: str) -> List[DRMConfig]: + """ + Resolve DRM configuration (Widevine + PlayReady) for a VOD clip. + + Delegates entirely to provider.get_drm(), which owns the + upfront-token fetch, asset selection, and DRMConfig construction. + """ + return self._provider.get_drm(clip_id) # ------------------------------------------------------------------ - # Movies branch dispatcher + # Root category — parsed from home layout # ------------------------------------------------------------------ - def _dispatch_movies( - self, category_path: List[str] - ) -> List[Union[VodCategory, VodItem]]: - depth = len(category_path) - if depth == 1: - # The normaliser may have collapsed e.g. ["video-tv","filme","genre","action"] - # into a single element ["/video-tv/filme/genre/action"]. Detect this by - # checking whether the path already contains "/genre/". - genre_prefix = RTLPlusDefaults.VOD_MOVIES_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_MOVIE, - genre_slug=genre_slug, - ) - genres = self._list_genres( - root_watch_path=RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH, - genre_slugs=RTLPlusDefaults.VOD_MOVIE_GENRE_SLUGS, + def _get_root_category(self) -> Dict[str, Any]: + """ + Return root VOD entries by parsing the Bedrock home layout. + + Block structure observed in /alias/home/layout: + block[0] → "Weiterschauen" (continue watching, bffPaginated) + block[1] → folder navigation block (items with item_type "folder") + """ + entries: List[Union[VodCategory, VodItem]] = [] + + layout = self._provider.fetch_layout( + layout_type="alias", + content_id="home", + ) + + if not layout: + logger.warning("Failed to fetch home layout for VOD root; returning empty") + return {"entries": [], "next_cursor": None, "total": 0} + + blocks = layout.get("blocks", []) + for block in blocks: + if block.get("type") != "bffPaginated": + continue + + block_title = ( + block.get("analytics", {}) + .get("tealium", {}) + .get("block_title", "") ) - 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( - element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE, - genre_slug=genre_slug, - ) - logger.warning(f"RTLPlusVodManager: movies path too deep: {category_path!r}") - return [] + + # Continue-watching items + if "Weiterschauen" in block_title or "Continue" in block_title: + for item in block.get("content", {}).get("items", []): + vod_item = self._extract_vod_item_from_block_item(item) + if vod_item: + entries.append(vod_item) + continue + + # Folder navigation block + for item in block.get("content", {}).get("items", []): + cat = self._extract_vod_category_from_block_item(item) + if cat: + entries.append(cat) + + return { + "entries": entries, + "next_cursor": None, + "total": len(entries), + } # ------------------------------------------------------------------ - # Series branch dispatcher + # Folder → program listing # ------------------------------------------------------------------ - def _dispatch_series( - self, category_path: List[str] - ) -> List[Union[VodCategory, VodItem]]: - depth = len(category_path) - if depth == 1: - # Same collapsed-path case as _dispatch_movies: detect a full genre URL. - genre_prefix = RTLPlusDefaults.VOD_SERIES_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_SERIES, - genre_slug=genre_slug, - ) - 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( - element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SERIES, - genre_slug=genre_slug, - ) - 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) - # ------------------------------------------------------------------ - - def _list_genres( + def _get_folder_contents( self, - root_watch_path: str, - genre_slugs: List[str], - ) -> List[VodCategory]: + folder_id: str, + cursor: Optional[str] = None, + page_size: int = 24, + ) -> Dict[str, Any]: """ - Build VodCategory nodes for each genre under *root_watch_path*. - - Each slug is validated via SeoUrlData which also provides the - localised German title from the breadcrumb. Invalid slugs are - silently skipped. + Return the programs/sub-folders inside a Bedrock folder. + Items that carry a video asset are surfaced as VodItem; + everything else becomes a VodCategory for further navigation. """ - results: List[VodCategory] = [] - for slug in genre_slugs: - watch_path = f"{root_watch_path}/genre/{slug}" - title = self._resolve_watch_path_title(watch_path) - if title is None: - logger.debug( - f"RTLPlusVodManager: genre slug '{slug}' did not resolve, skipping" - ) - continue - results.append( - VodCategory( - name=title, - content_id=watch_path, - provider="rtlplus", - ) - ) - logger.debug( - f"RTLPlusVodManager: _list_genres({root_watch_path}) → {len(results)} genres" + layout = self._provider.fetch_layout( + layout_type="folder", + content_id=folder_id, ) - return results + if not layout: + return {"entries": [], "next_cursor": None, "total": 0} - def _resolve_watch_path_title(self, watch_path: str) -> Optional[str]: - """ - Call SeoUrlData to verify a watch-path and return its leaf breadcrumb title. + entries: List[Union[VodCategory, VodItem]] = [] + for block in layout.get("blocks", []): + if block.get("type") != "bffPaginated": + continue + for item in block.get("content", {}).get("items", []): + if self._item_has_video_assets(item): + vod_item = self._extract_vod_item_from_block_item(item) + if vod_item: + entries.append(vod_item) + else: + cat = self._extract_vod_category_from_block_item(item) + if cat: + entries.append(cat) - Returns the localised title string on success, None if the path is - invalid or the query fails. - - Response shape (confirmed from live data): - data.urlDataByWatchPath.hierarchy.entries[-1].metadata.breadcrumbTitle - """ - data = self._graphql_get(RTLPlusGraphQL.seo_url_data(watch_path)) - if not data: - return None - try: - entries = data["data"]["urlDataByWatchPath"]["hierarchy"]["entries"] - if not entries: - return None - return entries[-1].get("metadata", {}).get("breadcrumbTitle") or None - except (KeyError, TypeError, IndexError): - return None + return {"entries": entries, "next_cursor": None, "total": len(entries)} # ------------------------------------------------------------------ - # Shared OverviewPage item list (movies + series, depth 2) + # Program → seasons # ------------------------------------------------------------------ - def _list_overview_page_items( + def _get_program_seasons( self, - element_type: str, - genre_slug: Optional[str] = None, - ) -> List[Union[VodCategory, VodItem]]: + program_id: str, + cursor: Optional[str] = None, + page_size: int = 24, + ) -> Dict[str, Any]: """ - 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" — 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 - the existing TopicWorlds branch at depth 1+. Movie items are returned - as VodItems directly. + Return a VodCategory per season for the given program. + Supports simple offset-based cursor pagination over the collected list. """ - results: List[Union[VodCategory, VodItem]] = [] - seen: set = set() - offset = 0 - limit = RTLPlusDefaults.VOD_OVERVIEW_PAGE_LIMIT - - while True: - params = RTLPlusGraphQL.overview_page( - element_type=element_type, - genres=[genre_slug] if genre_slug else None, - offset=offset, - limit=limit, - ) - data = self._graphql_get(params) - if not data: - break - - # Response key varies: live data uses "watchOverviewPage" - page = ( - data.get("data", {}).get("watchOverviewPage") - or data.get("data", {}).get("overviewPage") - or data.get("data", {}).get("OverviewPage") - or {} - ) - items = page.get("items") or page.get("elements") or [] - - for node in items: - if not node: - continue - node_id = node.get("id", "") - if not node_id or node_id in seen: - continue - seen.add(node_id) - - try: - if element_type == RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE: - vod = self._movie_to_item(node) - if vod: - results.append(vod) - else: - # Series → VodCategory so the user can drill to seasons - cat = self._format_to_category(node) - if cat: - results.append(cat) - except Exception as _node_err: - import traceback as _tb - logger.error( - "RTLPlusVodManager: node parse error (%r) node=%r\n%s" - % (_node_err, node, _tb.format_exc()) - ) - - page_info = page.get("pageInfo") or page.get("pagination") or {} - has_next = ( - page_info.get("hasNextPage") - or (page_info.get("total", 0) > offset + limit) - ) - if not has_next: - break - offset += limit - - logger.debug( - f"RTLPlusVodManager: _list_overview_page_items" - f"({element_type}, genre={genre_slug}) → {len(results)} items" + layout = self._provider.fetch_layout( + layout_type="program", + content_id=program_id, ) - return results + if not layout: + return {"entries": [], "next_cursor": None, "total": 0} - # ------------------------------------------------------------------ - # Depth 0 (series) — TopicWorlds - # ------------------------------------------------------------------ - - def _list_topic_worlds(self) -> List[VodCategory]: - """ - Fetch all TopicWorlds. Each becomes a VodCategory whose content_id - is its server RRN, passed back as category_path[0] at depth 1. - - Response shape: data.topicWorldsV2.elements[] - Pagination: data.topicWorldsV2.pageInfo.hasNextPage - """ - results: List[VodCategory] = [] - seen: set = set() - offset = 0 - take = 100 - - while True: - data = self._graphql_get(RTLPlusGraphQL.topic_worlds(take=take, offset=offset)) - if not data: - break - - payload = data.get("data", {}).get("topicWorldsV2", {}) - elements = payload.get("elements", []) - - for world in elements: - if not world: - continue - cat = self._topic_world_to_category(world) - if cat and cat.content_id not in seen: - seen.add(cat.content_id) - results.append(cat) - - page_info = payload.get("pageInfo", {}) - if not page_info.get("hasNextPage", False): - break - offset += take - - logger.debug(f"RTLPlusVodManager: _list_topic_worlds() → {len(results)} worlds") - return results - - # ------------------------------------------------------------------ - # Depth 1 (series) — Formats / Movies within a TopicWorld - # ------------------------------------------------------------------ - - def _list_formats_for_topic( - self, topic_rrn: str - ) -> List[Union[VodCategory, VodItem]]: - """ - Return the content items of a TopicWorld. - - We re-fetch TopicWorlds and locate the matching world, then iterate - its content items which may be Format or Movie nodes. - """ - data = self._graphql_get(RTLPlusGraphQL.topic_worlds(take=100, offset=0)) - if not data: - return [] - - worlds = data.get("data", {}).get("topicWorldsV2", {}).get("elements", []) - target = next( - (w for w in worlds if w and w.get("id") == topic_rrn), None - ) - if not target: - logger.warning( - f"RTLPlusVodManager: TopicWorld '{topic_rrn}' not found" - ) - return [] - - items = ( - target.get("content", {}).get("items", []) - or target.get("items", []) - ) - - results: List[Union[VodCategory, VodItem]] = [] - seen: set = set() - - for item in items: - if not item: + seasons: List[VodCategory] = [] + for block in layout.get("blocks", []): + if block.get("type") != "bffPaginated": continue - if item.get("__typename") == "Movie": - vod = self._movie_to_item(item) - if vod and vod.content_id not in seen: - seen.add(vod.content_id) - results.append(vod) - else: - cat = self._format_to_category(item) - if cat and cat.content_id not in seen: - seen.add(cat.content_id) - results.append(cat) - logger.debug( - f"RTLPlusVodManager: _list_formats_for_topic({topic_rrn}) " - f"→ {len(results)} items" - ) - return results + # Concurrent blocks = individual season selectors + for cb in ( + block.get("alternativeContent", {}).get("concurrentBlocks", []) + ): + seasons.append(VodCategory( + name=cb.get("title", "Unbekannte Staffel"), + content_id=f"season:{cb.get('id')}", + provider=self._provider.provider_name, + description=None, + child_count=( + cb.get("content", {}) + .get("pagination", {}) + .get("totalItems", 0) + ), + )) + + # Block itself is a season selector + tealium = block.get("analytics", {}).get("tealium", {}) + if tealium.get("template_name") == "SelectorCardListM": + seasons.append(VodCategory( + name=( + block.get("content", {}) + .get("title", {}) + .get("short", "Alle Staffeln") + ), + content_id=f"season:{block.get('id')}", + provider=self._provider.provider_name, + child_count=( + block.get("content", {}) + .get("pagination", {}) + .get("totalItems", 0) + ), + )) + + start = 0 + if cursor: + try: + start = int(cursor) + except ValueError: + start = 0 + + page = seasons[start : start + page_size] + next_cursor = str(start + page_size) if (start + page_size) < len(seasons) else None + + return {"entries": page, "next_cursor": next_cursor, "total": len(seasons)} # ------------------------------------------------------------------ - # Season RRN → Episodes (SeasonWithFormatAndEpisodes) + # Season block → episodes # ------------------------------------------------------------------ - def _list_episodes_for_season(self, season_rrn: str) -> List[VodItem]: + def _get_season_episodes( + self, + season_block_id: str, + cursor: Optional[str] = None, + page_size: int = 24, + ) -> Dict[str, Any]: """ - Fetch episodes for a season identified directly by its RRN. - Uses SeasonWithFormatAndEpisodes (paginated). - Response shape: data.season.episodes[] + Return VodItem per episode in a season block. + cursor is the Bedrock page number (1-based). """ - results: List[VodItem] = [] - seen: set = set() - offset = 0 - limit = 48 + page = 1 + if cursor: + try: + page = int(cursor) + except ValueError: + page = 1 - while True: - data = self._graphql_get( - RTLPlusGraphQL.season_with_episodes(season_rrn, offset=offset, limit=limit) - ) - if not data: - break - - season = data.get("data", {}).get("season", {}) or {} - episodes = season.get("episodes", []) - - for ep in episodes: - if not ep: - continue - ep_id = ep.get("id", "") - if not ep_id or ep_id in seen: - continue - seen.add(ep_id) - vod = self._episode_to_item(ep) - if vod: - results.append(vod) - - # SeasonWithFormatAndEpisodes doesn't paginate via pageInfo; - # stop when we receive fewer items than requested. - if len(episodes) < limit: - break - offset += limit - - logger.debug( - f"RTLPlusVodManager: _list_episodes_for_season({season_rrn}) " - f"→ {len(results)} episodes" + layout = self._provider.fetch_layout( + layout_type="block", + content_id=season_block_id, + block_page=page, + nb_pages=RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES, ) - return results + if not layout: + return {"entries": [], "next_cursor": None, "total": 0} + + episodes: List[VodItem] = [] + for item in layout.get("content", {}).get("items", []): + vod_item = self._extract_vod_item_from_block_item(item) + if vod_item: + episodes.append(vod_item) + + pagination = layout.get("content", {}).get("pagination", {}) + total = pagination.get("totalItems", len(episodes)) + next_page = pagination.get("nextPage") + next_cursor = str(next_page) if next_page else None + + return {"entries": episodes, "next_cursor": next_cursor, "total": total} # ------------------------------------------------------------------ - # Depth 2 (series) — Seasons for a Format + # Extraction helpers # ------------------------------------------------------------------ - def _list_seasons(self, format_rrn: str) -> List[VodCategory]: - """Fetch seasons using MRE first, falling back to the Format query.""" - # Primary: MRE - data = self._graphql_get(RTLPlusGraphQL.mre(format_rrn)) - seasons = self._extract_seasons_from_mre(data) if data else [] - - # Fallback: Format detail - if not seasons: - logger.debug( - f"RTLPlusVodManager: MRE empty → Format fallback for '{format_rrn}'" - ) - data = self._graphql_get(RTLPlusGraphQL.format(format_rrn)) - seasons = self._extract_seasons_from_format(data) if data else [] - - results: List[VodCategory] = [] - for node in seasons: - if not node: - continue - cat = self._season_to_category(node, format_rrn) - if cat: - results.append(cat) - - logger.debug( - f"RTLPlusVodManager: _list_seasons({format_rrn}) → {len(results)} seasons" - ) - return results - - @staticmethod - def _extract_seasons_from_mre(data: dict) -> list: - d = data.get("data", {}) - return ( - d.get("mre", {}).get("seasons", {}).get("items", []) - or d.get("mre", {}).get("seasons", []) - or d.get("format", {}).get("seasons", {}).get("items", []) - or [] - ) - - @staticmethod - def _extract_seasons_from_format(data: dict) -> list: - d = data.get("data", {}) - fmt = d.get("format", {}) or d.get("Format", {}) or {} - seasons = fmt.get("seasons", []) - # seasons may be a list directly or {"items": [...]} - if isinstance(seasons, dict): - seasons = seasons.get("items", []) - return seasons or [] - - # ------------------------------------------------------------------ - # Depth 3 (series) — Episodes for a Season - # ------------------------------------------------------------------ - - def _list_episodes(self, format_rrn: str, season_key: str) -> List[VodItem]: - """ - Fetch episodes for a Season. - - season_key format: "season:{discriminator}@{format_rrn}" - discriminator = ordinal string (ORDINAL) or YYYYMM (ANNUAL) - """ - discriminator = season_key.split("@")[0].replace("season:", "") - - data = self._graphql_get(RTLPlusGraphQL.mre(format_rrn)) - episodes_raw = ( - self._extract_episodes_from_mre(data, discriminator) if data else [] - ) - - if not episodes_raw: - logger.debug( - f"RTLPlusVodManager: MRE episodes empty → Format fallback for '{format_rrn}'" - ) - data = self._graphql_get(RTLPlusGraphQL.format(format_rrn)) - episodes_raw = ( - self._extract_episodes_from_format(data, discriminator) if data else [] - ) - - results: List[VodItem] = [] - seen: set = set() - for ep in episodes_raw: - if not ep: - continue - vod = self._episode_to_item(ep) - if vod and vod.content_id not in seen: - seen.add(vod.content_id) - results.append(vod) - - logger.debug( - f"RTLPlusVodManager: _list_episodes({format_rrn}, {season_key}) " - f"→ {len(results)} episodes" - ) - return results - - @staticmethod - def _extract_episodes_from_mre(data: dict, discriminator: str) -> list: - d = data.get("data", {}) - mre = d.get("mre", {}) or {} - - seasons = ( - mre.get("seasons", {}).get("items", []) - or mre.get("seasons", []) - ) - for season in seasons: - if not season: - continue - if RTLPlusVodManager._season_matches(season, discriminator): - return ( - season.get("episodes", {}).get("items", []) - or season.get("episodes", []) - ) - - all_eps = mre.get("episodes", {}).get("items", []) or mre.get("episodes", []) - return RTLPlusVodManager._filter_episodes_by_season(all_eps, discriminator) - - @staticmethod - def _extract_episodes_from_format(data: dict, discriminator: str) -> list: - d = data.get("data", {}) - fmt = d.get("format", {}) or d.get("Format", {}) or {} - - seasons = fmt.get("seasons", {}).get("items", []) or fmt.get("seasons", []) - for season in seasons: - if not season: - continue - if RTLPlusVodManager._season_matches(season, discriminator): - return ( - season.get("episodes", {}).get("items", []) - or season.get("episodes", []) - ) - - all_eps = fmt.get("episodes", {}).get("items", []) or fmt.get("episodes", []) - return RTLPlusVodManager._filter_episodes_by_season(all_eps, discriminator) - - @staticmethod - def _season_matches(season: dict, discriminator: str) -> bool: - ordinal = str(season.get("ordinal", "")) - year = season.get("year", 0) - month = season.get("month", 0) - annual = f"{year}{month:02d}" if year else "" - return ordinal == discriminator or annual == discriminator - - @staticmethod - def _filter_episodes_by_season(episodes: list, discriminator: str) -> list: - result = [] - for ep in episodes: - if not ep: - continue - ep_season = ep.get("episodeSeason", {}) or {} - season_data = ep_season.get("season", {}) or {} - season_type = ep_season.get("seasonType", "ORDINAL") - - if season_type == "ORDINAL": - if str(season_data.get("ordinal", "")) == discriminator: - result.append(ep) - elif season_type == "ANNUAL": - year = season_data.get("year", 0) - month = season_data.get("month", 0) - annual = f"{year}{month:02d}" if year else "" - if annual == discriminator: - result.append(ep) - return result - - # ------------------------------------------------------------------ - # Node → VodModel converters - # ------------------------------------------------------------------ - - @staticmethod - def _topic_world_to_category(node: dict) -> Optional[VodCategory]: - rrn = node.get("id", "") - title = node.get("title", "") - if not rrn or not title: + def _extract_vod_item_from_layout( + self, layout: Dict, clip_id: str + ) -> Optional[VodItem]: + """Build a VodItem from a full video layout (detail view).""" + assets = self._provider.extract_video_assets(layout) + if not assets: return None - image = node.get("image", {}) or {} - logo_url = image.get("thumbnail") or image.get("logo") + entity = layout.get("entity", {}) + metadata = entity.get("metadata", {}) + parent = layout.get("parent", {}) + video_meta = layout.get("seo", {}).get("video", {}) - return VodCategory( - name=title, - content_id=rrn, - provider="rtlplus", - logo_url=logo_url, - description=( - node.get("descriptionV2") - or node.get("emptyFormatText") - or node.get("description") - ), + vod_item = VodItem.create_episode( + name=metadata.get("title", clip_id), + content_id=clip_id, + provider=self._provider.provider_name, + season_number=video_meta.get("season"), + episode_number=video_meta.get("episode"), ) + vod_item.description = metadata.get("description") + vod_item.logo_url = self._extract_thumbnail_from_layout(layout) + vod_item.duration_seconds = video_meta.get("duration") + vod_item.genre = parent.get("seo", "") + vod_item.series_title = parent.get("name") - @staticmethod - def _format_to_category(node: dict) -> Optional[VodCategory]: - rrn = node.get("id", "") - title = node.get("title", "") - if not rrn or not title: - return None - wi = node.get("watchImages") or {} - logo_url = ( - (wi.get("artworkLandscape") or {}).get("absoluteUri") - or (wi.get("artworkPortrait") or {}).get("absoluteUri") - ) - return VodCategory( - name=title, - content_id=rrn, - provider="rtlplus", - logo_url=logo_url, - description=( - node.get("descriptionV2") - or node.get("emptyFormatText") - or node.get("description") - ), - child_count=node.get("numberOfSeasons"), - ) + if any(a.get("drm") for a in assets): + vod_item.use_cdm = True + vod_item.cdm_type = "widevine" - @staticmethod - def _season_to_category(node: dict, format_rrn: str) -> Optional[VodCategory]: - season_type = node.get("seasonType") or node.get("type", "ORDINAL") - season_data = node.get("season", node) + return vod_item - title = node.get("title") or node.get("titleOverride") - discriminator: Optional[str] = None - - if season_type == "ORDINAL": - ordinal = season_data.get("ordinal") or node.get("ordinal") - if ordinal is None: - return None - discriminator = str(ordinal) - if not title: - override = season_data.get("titleOverride") or node.get("titleOverride") - title = override or f"Staffel {ordinal}" - - elif season_type == "ANNUAL": - year = season_data.get("year") or node.get("year") - month = season_data.get("month") or node.get("month") - if not year: - return None - discriminator = f"{year}{month:02d}" if month else str(year) - if not title: - title = ( - f"{calendar.month_name[month]} {year}" if month else str(year) - ) - else: - server_id = node.get("id", "") - if not server_id: - return None - discriminator = server_id - title = title or server_id - - # Prefer the real season RRN when available (from Format.seasons[].id) - # so the client can pass it back and route via _list_episodes_for_season. - # Fall back to synthetic key for MRE nodes that lack an explicit id. - season_rrn = node.get("id", "") - content_id = season_rrn if season_rrn.startswith("rrn:watch:videohub:season:") else f"season:{discriminator}@{format_rrn}" - - return VodCategory( - name=title, - content_id=content_id, - provider="rtlplus", - child_count=node.get("numberOfEpisodes"), - ) - - @staticmethod - def _episode_to_item(node: dict) -> Optional[VodItem]: - rrn = node.get("id", "") - title = node.get("title", "") - if not rrn or not title: + def _extract_vod_item_from_block_item( + self, item: Dict + ) -> Optional[VodItem]: + """Build a VodItem from a list/block item (episode row).""" + if item.get("itemType") != "classic": return None - ep_season = node.get("episodeSeason", {}) or {} - season_data = ep_season.get("season", {}) or {} - season_type = ep_season.get("seasonType", "ORDINAL") - - if season_type == "ORDINAL": - season_number = season_data.get("ordinal") - elif season_type == "ANNUAL": - year = season_data.get("year", 0) - month = season_data.get("month", 0) - season_number = year * 100 + month if year else None - else: - season_number = None - - wi = node.get("watchImages") or {} - logo_url = ( - (wi.get("default") or {}).get("absoluteUri") - or (wi.get("portrait") or {}).get("absoluteUri") + item_content = item.get("itemContent", {}) + target = ( + item_content.get("action", {}) + .get("target", {}) ) + value_layout = target.get("value_layout", {}) - vod = VodItem.create_episode( - name=title, - content_id=rrn, - provider="rtlplus", + if value_layout.get("type") != "video": + return None + + clip_id = value_layout.get("id") + if not clip_id: + return None + + title = item_content.get("title", "") + extra_title = item_content.get("extraTitle", "") + full_title = f"{title} - {extra_title}" if extra_title else title + + highlight = item_content.get("highlight", "") + season_number: Optional[int] = None + episode_number: Optional[int] = None + + m = re.search(r"Staffel\s*(\d+)", highlight, re.IGNORECASE) + if m: + season_number = int(m.group(1)) + m = re.search(r"Folge\s*(\d+)", highlight, re.IGNORECASE) + if m: + episode_number = int(m.group(1)) + + vod_item = VodItem.create_episode( + name=full_title or title, + content_id=clip_id, + provider=self._provider.provider_name, season_number=season_number, - episode_number=node.get("number"), - logo_url=logo_url, - duration_seconds=node.get("durationInSecondsV2"), + episode_number=episode_number, ) - if node.get("tier") == "PREMIUM": - vod.use_cdm = True - vod.cdm_type = "widevine" - return vod + vod_item.description = item_content.get("description") + vod_item.logo_url = self._extract_thumbnail(item_content) + vod_item.duration_seconds = self._extract_duration(item_content) + vod_item.progress = item_content.get("progress", 0) - @staticmethod - def _movie_to_item(node: dict) -> Optional[VodItem]: - rrn = node.get("id", "") - title = node.get("title", "") - if not rrn or not title: + return vod_item + + def _extract_vod_category_from_block_item( + self, item: Dict + ) -> Optional[VodCategory]: + """Build a VodCategory from a list/block item (folder or program row).""" + if item.get("itemType") != "classic": return None - wi = node.get("watchImages") or {} - logo_url = ( - (wi.get("artworkLandscape") or {}).get("absoluteUri") - or (wi.get("artworkPortrait") or {}).get("absoluteUri") + item_content = item.get("itemContent", {}) + value_layout = ( + item_content.get("action", {}) + .get("target", {}) + .get("value_layout", {}) ) - genres = node.get("genres", []) - vod = VodItem.create_movie( - name=title, - content_id=rrn, - provider="rtlplus", - logo_url=logo_url, - duration_seconds=node.get("durationInSecondsV2"), - release_year=node.get("productionYear"), - genre=genres[0] if genres else None, - description=( - node.get("descriptionV2") - or node.get("emptyFormatText") - or node.get("description") - ), + layout_type = value_layout.get("type") + if layout_type not in ("folder", "program"): + return None + + content_id = value_layout.get("id") + if not content_id: + return None + + return VodCategory( + name=item_content.get("title", "Unbekannt"), + content_id=f"{layout_type}:{content_id}", + provider=self._provider.provider_name, + logo_url=self._extract_thumbnail(item_content), + description=item_content.get("description"), + ) + + @staticmethod + def _item_has_video_assets(item: Dict) -> bool: + """True when a block item directly carries playable video assets.""" + return bool( + item.get("itemContent", {}) + .get("video", {}) + .get("assets") ) - if node.get("tier") == "PREMIUM": - vod.use_cdm = True - vod.cdm_type = "widevine" - return vod # ------------------------------------------------------------------ - # RRN normalisation + # Thumbnail / duration helpers (pure, no I/O) # ------------------------------------------------------------------ @staticmethod - def _to_format_rrn(value: str) -> str: - """Normalise a format identifier (full RRN, numeric ID, or slug) to a full RRN.""" - if value.startswith("rrn:"): - return value - if value.isdigit(): - return f"rrn:watch:videohub:format:{value}" - tail = value.rsplit("-", 1)[-1] - if tail.isdigit(): - return f"rrn:watch:videohub:format:{tail}" - return value + def _extract_thumbnail(item_content: Dict) -> Optional[str]: + image = item_content.get("image", {}) + if not image: + return None + for ratio in ("16:9", "3:1", "1:1", "2:3"): + image_id = image.get("idsByRatio", {}).get(ratio) + if image_id: + return f"https://images.rtl.de/{image_id}?format=webp&width=400" + image_id = image.get("id") + if image_id: + return f"https://images.rtl.de/{image_id}?format=webp&width=400" + return None + + @staticmethod + def _extract_thumbnail_from_layout(layout: Dict) -> Optional[str]: + image_id = layout.get("seo", {}).get("image", {}).get("id") + if image_id: + return f"https://images.rtl.de/{image_id}?format=webp&width=400" + return None + + @staticmethod + def _extract_duration(item_content: Dict) -> Optional[int]: + """Parse HH:MM:SS from progressBar.endTitle → seconds.""" + end_title = item_content.get("progressBar", {}).get("endTitle", "") + if end_title: + parts = end_title.split(":") + if len(parts) == 3: + try: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + except ValueError: + pass + return None # ------------------------------------------------------------------ - # GraphQL helper + # Cache management # ------------------------------------------------------------------ - def _graphql_get(self, params: dict) -> Optional[dict]: + def invalidate_cache(self, content_id: Optional[str] = None) -> None: """ - Execute a GraphQL GET against the RTL+ CDN gateway. - GraphQL-level errors are logged as debug; callers receive partial data. + Invalidate cached layouts for the given node, or everything. + + content_id uses the same prefix conventions as get_vod_category. """ - try: - response = self._provider.http_manager.get( - self._graphql_url, - operation="vod_graphql", - params=params, - headers=self._headers(), - ) - response.raise_for_status() - data = response.json() + if not content_id: + self._provider.invalidate_layout_cache() + return - for err in data.get("errors", []): - logger.debug( - f"RTLPlusVodManager: GraphQL " - f"[{err.get('extensions', {}).get('code', '?')}] " - f"{err.get('message', '')}" - ) - return data - - except Exception as e: - logger.error( - f"RTLPlusVodManager: request failed " - f"(op={params.get('operationName', '?')}): {e}" + if content_id.startswith("program:"): + program_id = content_id[len("program:"):] + # Program layouts are cached under "program:::" + cache_key = ( + f"program:{program_id}" + f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}" + f":{RTLPlusDefaults.DEFAULT_NB_PAGES}" ) - return None \ No newline at end of file + self._provider.invalidate_layout_cache(cache_key) + + elif content_id.startswith("season:"): + season_id = content_id[len("season:"):].split("?")[0] + cache_key = ( + f"block:{season_id}" + f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}" + f":{RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES}" + ) + self._provider.invalidate_layout_cache(cache_key) + + elif content_id.startswith("folder:"): + folder_id = content_id[len("folder:"):] + cache_key = ( + f"folder:{folder_id}" + f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}" + f":{RTLPlusDefaults.DEFAULT_NB_PAGES}" + ) + self._provider.invalidate_layout_cache(cache_key) + + else: + # Unknown prefix — wipe everything rather than silently doing nothing + logger.debug( + f"invalidate_cache: unknown content_id format {content_id!r}, " + "clearing full layout cache" + ) + self._provider.invalidate_layout_cache() \ No newline at end of file