From 95f657393daafd4c9716b23558e0cdc00ad60444 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 7 May 2026 20:38:16 +0200 Subject: [PATCH] Implement RTL VOD series/episodes --- .../base/models/drm/license_config.py | 23 +++++ .../providers/rtlplus/vod_manager.py | 93 ++++++++++++++++--- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/lib/streaming_providers/base/models/drm/license_config.py b/lib/streaming_providers/base/models/drm/license_config.py index 9a1c421..19c4275 100644 --- a/lib/streaming_providers/base/models/drm/license_config.py +++ b/lib/streaming_providers/base/models/drm/license_config.py @@ -133,6 +133,15 @@ class LicenseConfig: """Normalize req_headers to URL-encoded string and key IDs in keyids mapping.""" self.req_headers = self._normalize_headers(self.req_headers) + # NEW: Auto-encode req_data if it looks like plain text + if self.req_data and not self._is_base64(self.req_data): + # Assume it's plain text that needs encoding + try: + self.req_data = safe_base64_encode(self.req_data.encode('utf-8')) + except Exception: + # If it fails, keep as-is but validation will catch it + pass + if self.keyids: normalized_keyids = {} for kid, key in self.keyids.items(): @@ -144,6 +153,20 @@ class LicenseConfig: pass self.keyids = normalized_keyids + @staticmethod + def _is_base64(s: str) -> bool: + """Check if string appears to be base64 encoded.""" + import base64 + import re + # Base64 pattern: only valid chars, length multiple of 4 + if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', s): + return False + try: + base64.b64decode(s) + return True + except Exception: + return False + def validate(self) -> None: """ Validate the license configuration. diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index b1f282b..9b22a42 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -39,8 +39,8 @@ class RTLPlusVodManager: root └── folder_ (Bedrock folder, e.g. folder_3 = Serien) └── program_ (program layout → seasons or direct video) - ├── month_ (monthly archive selector) - │ └── episodes (via block fetch) + ├── month_:: (monthly archive selector) + │ └── episodes (via /program/{id}/block/{block_id} fetch) ├── season_ (numbered season selector) │ └── episodes (via block fetch) └── clip_id (video layout, playable) @@ -87,7 +87,7 @@ class RTLPlusVodManager: - "folder_" → Bedrock folder - "program_" → program/series (handles movies, numbered seasons, monthly archives) - "season_" → numbered season block (Staffel 1, 2, etc.) - - "month_" → monthly archive block (2026-05, 2026-04, etc.) + - "month_::" → monthly archive block (2026-05, 2026-04, etc.) - "clip_" → direct clip (playable item) - "program_/clip_" → explicit program+clip path """ @@ -111,10 +111,14 @@ class RTLPlusVodManager: return {"entries": [vod_item], "next_cursor": None, "total": 1} return {"entries": [], "next_cursor": None, "total": 0} - # Handle month block (monthly episode archives) + # Handle month block (monthly episode archives) - NEW FORMAT with program_id if content_id.startswith("month_"): - block_id = content_id[6:] # Remove "month_" prefix - return self._get_block_episodes(block_id, cursor, page_size, block_type="month") + rest = content_id[6:] # Remove "month_" prefix + if "::" in rest: + program_id, block_id = rest.split("::", 1) + return self._get_program_block_episodes(program_id, block_id, cursor, page_size) + # Legacy fallback (shouldn't occur after fix) + return self._get_block_episodes(rest, cursor, page_size, block_type="month") # Handle numbered season block if content_id.startswith("season_"): @@ -145,6 +149,65 @@ class RTLPlusVodManager: logger.warning(f"Unrecognised VOD content_id format: {content_id!r}") return {"entries": [], "next_cursor": None, "total": 0} + def _get_program_block_episodes( + self, + program_id: str, + block_id: str, + cursor: Optional[str] = None, + page_size: int = 24, + ) -> Dict[str, Any]: + """ + Fetch episodes from a month block using the /program/{id}/block/{block_id} endpoint. + The block_id must be the full page_... ID from concurrentBlocks. + """ + page = 1 + if cursor: + try: + page = int(cursor) + except ValueError: + page = 1 + + oauth_token = self._provider.get_user_bearer_token() or self.auth.get_bearer_token() + if not oauth_token: + logger.error("No OAuth token available for program block episodes") + return {"entries": [], "next_cursor": None, "total": 0} + + bedrock_token = self.auth.get_bedrock_token() + if not bedrock_token: + logger.error("No Bedrock token available for program block episodes") + return {"entries": [], "next_cursor": None, "total": 0} + + location = f"{self.cfg.base_website}p_{program_id}-p_{program_id}" + + url = f"{self.cfg.bedrock_layout_base}/program/{program_id}/block/{block_id}" + params = { + "nbPages": RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES, + "page": page, + } + headers = self.cfg.get_layout_headers(oauth_token, bedrock_token, location) + + try: + response = self.http.get(url, headers=headers, params=params, operation="api") + response.raise_for_status() + layout = response.json() + except Exception as e: + logger.error(f"Failed to fetch program block {block_id} for program {program_id}: {e}") + 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 + + logger.debug(f"Fetched {len(episodes)}/{total} episodes from program {program_id} block {block_id} (page {page})") + return {"entries": episodes, "next_cursor": next_cursor, "total": total} + def _get_block_episodes( self, block_id: str, @@ -153,7 +216,7 @@ class RTLPlusVodManager: block_type: str = "block" ) -> Dict[str, Any]: """ - Fetch episodes from a block (works for both numbered seasons and monthly archives). + Fetch episodes from a block (works for numbered seasons). Args: block_id: The block ID (e.g., "c7916bb9-d111-49db-9a99-ab40ef6413da_39638") @@ -168,6 +231,7 @@ class RTLPlusVodManager: except ValueError: page = 1 + # Use generic block endpoint for numbered seasons layout = self._provider.fetch_layout( layout_type="block", content_id=block_id, @@ -211,7 +275,7 @@ class RTLPlusVodManager: if not layout: return {"entries": [], "next_cursor": None, "total": 0} - _, current_episodes = self._extract_season_selector_with_episodes(layout) + _, current_episodes = self._extract_season_selector_with_episodes(layout, program_id) if not current_episodes: return {"entries": [], "next_cursor": None, "total": 0} @@ -448,7 +512,7 @@ class RTLPlusVodManager: return {"entries": [], "next_cursor": None, "total": 0} # FIRST: Check for monthly archive selector (series with monthly episodes) - season_selector, current_episodes = self._extract_season_selector_with_episodes(layout) + season_selector, current_episodes = self._extract_season_selector_with_episodes(layout, program_id) if season_selector: logger.debug(f"Found monthly selector with {len(season_selector)} months for program {program_id}") return self._handle_series_response(program_id, season_selector, current_episodes, cursor, page_size) @@ -541,7 +605,7 @@ class RTLPlusVodManager: next_cursor = str(start + page_size) if (start + page_size) < len(numbered_seasons) else None return {"entries": page, "next_cursor": next_cursor, "total": len(numbered_seasons)} - def _extract_season_selector_with_episodes(self, layout: Dict) -> Tuple[List[VodCategory], List[VodItem]]: + def _extract_season_selector_with_episodes(self, layout: Dict, program_id: Optional[str] = None) -> Tuple[List[VodCategory], List[VodItem]]: logger.debug("Starting _extract_season_selector_with_episodes") seasons = [] current_episodes = [] @@ -579,9 +643,11 @@ class RTLPlusVodManager: .get("totalItems", 0) ) + # Store with program_id for proper endpoint routing + content_id = f"month_{program_id}::{block_id}" if program_id else f"month_{block_id}" seasons.append(VodCategory( name=month_title, - content_id=f"month_{block_id}", + content_id=content_id, provider=self._provider.provider_name, child_count=total_episodes, )) @@ -1213,9 +1279,10 @@ class RTLPlusVodManager: self._provider.invalidate_layout_cache(cache_key) elif content_id.startswith("month_"): - month_id = content_id[len("month_"):].split("?")[0] + rest = content_id[6:] + block_id = rest.split("::", 1)[-1] if "::" in rest else rest.split("?")[0] cache_key = ( - f"block:{month_id}" + f"block:{block_id}" f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}" f":{RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES}" )