diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index 451aab9..a1d4413 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -1,8 +1,9 @@ # streaming_providers/providers/rtlplus/provider.py + import time from datetime import datetime -from typing import ClassVar, Dict, List, Optional, Tuple -from ..lib_drmtoday import create_drmtoday_configs +from typing import ClassVar, Dict, List, Optional, Tuple, Any +from ..lib_drmtoday import create_drmtoday_configs from ...base.models import DRMConfig, StreamingChannel, Event from ...base.models.proxy_models import ProxyConfig @@ -104,6 +105,11 @@ class RTLPlusProvider(StreamingProvider): def supported_auth_types(self) -> List[str]: return ["user_credentials"] + @property + def implements_vod(self) -> bool: + """RTL+ has a browsable VOD catalogue.""" + return True + # -------------------------------------------------------------------------- # Common Layout Methods # -------------------------------------------------------------------------- @@ -116,9 +122,10 @@ class RTLPlusProvider(StreamingProvider): nb_pages: int = None, location: str = None, force_refresh: bool = False, + **kwargs, ) -> Optional[Dict]: """ - Fetch any layout (live/video/folder/program/block/alias) with caching. + Fetch any layout (live/video/folder/program/block/alias/search) with caching. """ if block_page is None: block_page = RTLPlusDefaults.DEFAULT_BLOCK_PAGE @@ -142,7 +149,12 @@ class RTLPlusProvider(StreamingProvider): ) return None - cache_key = f"{layout_type}:{clean_content_id}:{block_page}:{nb_pages}" + # Build cache key (include query for search) + if layout_type == "search": + query = kwargs.get("query", "") + cache_key = f"{layout_type}:{query}:{block_page}:{nb_pages}" + else: + cache_key = f"{layout_type}:{clean_content_id}:{block_page}:{nb_pages}" now = time.time() if not force_refresh and cache_key in self._layout_cache: @@ -169,15 +181,26 @@ class RTLPlusProvider(StreamingProvider): if location is None and layout_type in ("live", "video", "folder", "program", "alias"): location = f"{self.rtl_config.base_website}{clean_content_id}" - # Build request - handle alias layouts specially + # Build request - handle special layout types if layout_type == "alias": url = f"{self.rtl_config.bedrock_layout_base}/alias/{clean_content_id}/layout" + elif layout_type == "search": + url = f"{self.rtl_config.bedrock_layout_base}/frontspace/search/layout" + # Validate query parameter for search + query = kwargs.get("query") + if not query: + logger.error("fetch_layout: 'search' layout requires 'query' parameter") + return None else: url = self.rtl_config.get_layout_url(layout_type, clean_content_id) headers = self.rtl_config.get_layout_headers(oauth_token, bedrock_token, location) params = {"blockPage": block_page, "nbPages": nb_pages} + # Add query parameter for search + if layout_type == "search": + params["query"] = kwargs.get("query") + try: response = self.http_manager.get( url, headers=headers, params=params, operation="api" @@ -193,11 +216,11 @@ class RTLPlusProvider(StreamingProvider): return None def fetch_block_page( - self, - block_id: str, - page: int, - nb_pages: int = 3, - service_id: str = "rtlplus_root", + self, + block_id: str, + page: int, + nb_pages: int = 3, + service_id: str = "rtlplus_root", ) -> Optional[Dict]: """ Fetch a specific page of a service block from the Bedrock API. @@ -275,9 +298,9 @@ class RTLPlusProvider(StreamingProvider): @staticmethod def _extract_items_from_block( - layout_data: Dict, - block_type: str = None, - item_type: str = None + layout_data: Dict, + block_type: str = None, + item_type: str = None ) -> List[Dict]: """ Extract items from layout blocks with optional filtering. @@ -319,10 +342,10 @@ class RTLPlusProvider(StreamingProvider): return layout_data.get("pagination", {}) def extract_best_manifest_url( - self, - assets: List[Dict], - preferred_quality: str = None, - preferred_format: str = None, + self, + assets: List[Dict], + preferred_quality: str = None, + preferred_format: str = None, ) -> Optional[str]: """ Extract the best manifest URL from assets based on preferences. @@ -373,8 +396,8 @@ class RTLPlusProvider(StreamingProvider): try: current_level = self.authenticator.get_current_token_level() force_upgrade = ( - self.authenticator.has_user_credentials() - and current_level != TokenAuthLevel.USER_AUTHENTICATED + self.authenticator.has_user_credentials() + and current_level != TokenAuthLevel.USER_AUTHENTICATED ) bearer_token = self.authenticator.get_bearer_token(force_upgrade=force_upgrade) except Exception as e: @@ -404,10 +427,10 @@ class RTLPlusProvider(StreamingProvider): # -------------------------------------------------------------------------- def get_events( - self, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - **kwargs, + self, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kwargs, ) -> List[Event]: """ Fetch upcoming / live RTL+ events from the Bedrock layout API. @@ -427,6 +450,32 @@ class RTLPlusProvider(StreamingProvider): def get_vod_category(self, content_id: str = "", **kwargs): return self._vod_manager.get_vod_category(content_id=content_id, **kwargs) + def search_vod( + self, + query: str, + cursor: Optional[str] = None, + page_size: int = 24, + **kwargs, + ) -> Dict[str, Any]: + """ + Search the RTL+ VOD catalogue. + + Args: + query: Search string + cursor: Pagination cursor (opaque token) + page_size: Number of results per page + **kwargs: Additional arguments + + Returns: + Dictionary with 'entries', 'next_cursor', and 'total' fields + """ + return self._vod_manager.search_vod( + query=query, + cursor=cursor, + page_size=page_size, + **kwargs + ) + # -------------------------------------------------------------------------- # Manifest & DRM (Unified) # -------------------------------------------------------------------------- diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index 820a828..ac2cad7 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -157,6 +157,153 @@ class RTLPlusVodManager: logger.warning(f"Unrecognised VOD content_id format: {content_id!r}") return {"entries": [], "next_cursor": None, "total": 0} + def search_vod( + self, + query: str, + cursor: Optional[str] = None, + page_size: int = 24, + **kwargs, + ) -> Dict[str, Any]: + """ + Search the RTL+ catalogue for programs and clips matching *query*. + + Uses the Bedrock ``frontspace/search/layout`` endpoint, which returns + the same block/item structure as every other layout response. Results + are split into typed blocks (e.g. "Serien & Shows", "Filme") but we + flatten them into a single list because the caller's UI shows one + combined result set. + + Pagination: + The Bedrock search endpoint accepts ``blockPage`` (1-indexed). + We map *cursor* → ``blockPage`` using the same integer-cursor + convention used by the rest of the VOD manager. + + Args: + query: Free-text search string. + cursor: Opaque page token (stringified integer, None = page 1). + page_size: Ignored — the API controls page size; kept for + interface compatibility. + + Returns: + ``{"entries": [...], "next_cursor": str | None, "total": int}`` + """ + if not query or not query.strip(): + return {"entries": [], "next_cursor": None, "total": 0} + + block_page = 1 + if cursor: + try: + block_page = int(cursor) + except ValueError: + block_page = 1 + + layout = self._provider.fetch_layout( + layout_type="search", + content_id="search", # unused by the search branch but + # required by fetch_layout signature + block_page=block_page, + nb_pages=RTLPlusDefaults.DEFAULT_NB_PAGES, + query=query, + ) + + if not layout: + logger.warning(f"RTL+ search returned no layout for query={query!r}") + return {"entries": [], "next_cursor": None, "total": 0} + + return self._parse_search_layout(layout, block_page) + + # ------------------------------------------------------------------ + # Search-specific parsing helpers + # ------------------------------------------------------------------ + + def _parse_search_layout( + self, + layout: Dict, + current_page: int, + ) -> Dict[str, Any]: + """ + Flatten all ``bffPaginated`` blocks from a search layout into a + single list of VodCategory / VodItem entries. + + The search response uses the identical block→item structure as folder + and program layouts, so we reuse the existing extraction helpers + directly. Items whose action target resolves to a ``program`` layout + become VodCategory objects (browsable); items pointing to a ``video`` + layout become VodItem objects (playable). + + Pagination is block-level: the API returns one or more blocks per + page; we advance the page when *any* block reports a next page. + """ + entries: list = [] + has_next_page = False + + for block in layout.get("blocks", []): + if block.get("type") != "bffPaginated": + continue + + content = block.get("content") or {} + items = content.get("items") or [] + + for item in items: + if not item or item.get("itemType") != "classic": + continue + + item_content = item.get("itemContent") or {} + + # Skip future scheduled events (same filter as VOD root) + if self._is_future_event(item_content): + continue + + entry = self._extract_search_entry(item) + if entry is not None: + entries.append(entry) + + # Detect pagination: does this block have a next page? + pagination = content.get("pagination") or {} + if pagination.get("nextPage"): + has_next_page = True + + next_cursor = str(current_page + 1) if has_next_page else None + + logger.debug( + f"RTL+ search page {current_page}: " + f"{len(entries)} entries, next_cursor={next_cursor!r}" + ) + return { + "entries": entries, + "next_cursor": next_cursor, + "total": len(entries), # Bedrock search does not expose a grand total + } + + def _extract_search_entry(self, item: Dict): + """ + Convert one search result item into a VodCategory or VodItem. + + Decision tree (mirrors the rest of the manager): + - target.type == "program" → VodCategory (series / show landing page) + - target.type == "video" → VodItem (directly playable clip) + - target.type == "folder" → VodCategory (genre folder) + - anything else → None (silently skipped) + + We deliberately reuse ``_extract_vod_category_from_block_item`` and + ``_extract_vod_item_from_block_item`` so that any future improvements + to those methods automatically benefit search results too. + """ + item_content = item.get("itemContent") or {} + action = item_content.get("action") or {} + target = unwrap_target(action.get("target") or {}) + layout_type = target.get("value_layout", {}).get("type") + + if layout_type in ("program", "folder"): + return self._extract_vod_category_from_block_item(item) + + if layout_type == "video": + return self._extract_vod_item_from_block_item(item) + + # Unrecognised layout type — skip gracefully + logger.debug(f"RTL+ search: skipping item with layout_type={layout_type!r}") + return None + def _get_program_block_episodes( self, program_id: str, diff --git a/routes/vod.py b/routes/vod.py index a218980..0944e7b 100644 --- a/routes/vod.py +++ b/routes/vod.py @@ -31,12 +31,48 @@ GET /api/providers//vod/ "total": 120 # null when provider does not expose total } +Search endpoints +---------------- +GET /api/providers//vod/search + Search the VOD catalogue of a single provider. + +GET /api/vod/search + Search the VOD catalogue across all enabled providers that implement VOD. + + Query parameters: + q Required. Free-text search string. + cursor Opaque continuation token (single-provider search only). + size Number of entries to request per page (default: 24). + + Single-provider response: + { + "provider": "discovery_de", + "query": "football", + "entries": [...], + "count": 8, + "next_cursor": "", + "total": 42 + } + + Cross-provider response: + { + "query": "football", + "results": { + "discovery_de": {"entries": [...], "count": 3}, + "rtlplus": {"entries": [...], "count": 5} + }, + "total_count": 8 + } + Stream / manifest / DRM endpoints for VodItems are in streams.py: GET /api/providers//vod//manifest GET /api/providers//vod//stream/index.mpd GET /api/providers//vod//drm These are registered in streams.py (same pattern as channels and events) and must be set up BEFORE setup_vod_routes() so Bottle matches them first. + +NOTE: Search routes are registered before the wildcard so +that /vod/search is not swallowed by get_vod_node. """ from bottle import request, response @@ -122,6 +158,75 @@ def setup_vod_routes(app, manager): "total": result.get("total"), } + # ------------------------------------------------------------------ + # Search routes — registered BEFORE the wildcard so + # that /vod/search is not captured by get_vod_node. + # ------------------------------------------------------------------ + + @app.route("/api/providers//vod/search", method="GET") + def search_vod(provider): + """Search the VOD catalogue of a single provider.""" + query = request.query.get("q", "").strip() + if not query: + response.status = 400 + return {"error": "Missing required query parameter 'q'", "provider": provider} + + cursor, page_size = _parse_paging_params() + try: + result = manager.search_vod( + provider_name=provider, + query=query, + cursor=cursor, + page_size=page_size, + ) + except ValueError as e: + response.status = 404 + return {"error": "Provider not found", "message": str(e), "provider": provider} + except Exception as e: + logger.error(f"Failed to search VOD in provider '{provider}': {e}") + response.status = 500 + return {"error": "Failed to search VOD", "message": str(e), "provider": provider} + + serialized = _serialize(result["entries"], provider) + response.status = 200 + return { + "provider": provider, + "query": query, + "entries": serialized, + "count": len(serialized), + "next_cursor": result.get("next_cursor"), + "total": result.get("total"), + } + + @app.route("/api/vod/search", method="GET") + def search_all_vod(): + """Search the VOD catalogue across all enabled providers.""" + query = request.query.get("q", "").strip() + if not query: + response.status = 400 + return {"error": "Missing required query parameter 'q'"} + + try: + provider_results = manager.search_all_vod(query=query) + except Exception as e: + logger.error(f"Failed to search VOD across providers: {e}") + response.status = 500 + return {"error": "Failed to search VOD", "message": str(e)} + + results = {} + total_count = 0 + for provider_name, entries in provider_results.items(): + serialized = _serialize(entries, provider_name) + results[provider_name] = {"entries": serialized, "count": len(serialized)} + total_count += len(serialized) + + response.status = 200 + return { + "query": query, + "results": results, + "total_count": total_count, + } + @app.route("/api/providers//vod/", method="GET") def get_vod_node(provider, content_id): """