Add RTL search VOD

This commit is contained in:
Nirvana
2026-05-12 15:35:18 +02:00
parent cc987ae48d
commit c5ed777d2e
3 changed files with 324 additions and 23 deletions
@@ -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)
# --------------------------------------------------------------------------
@@ -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,
+105
View File
@@ -31,12 +31,48 @@ GET /api/providers/<provider>/vod/<path:path>
"total": 120 # null when provider does not expose total
}
Search endpoints
----------------
GET /api/providers/<provider>/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": "<opaque>",
"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/<provider>/vod/<vod_id>/manifest
GET /api/providers/<provider>/vod/<vod_id>/stream/index.mpd
GET /api/providers/<provider>/vod/<vod_id>/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 <content_id:path> 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 <content_id:path> wildcard so
# that /vod/search is not captured by get_vod_node.
# ------------------------------------------------------------------
@app.route("/api/providers/<provider>/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/<provider>/vod/<content_id:path>", method="GET")
def get_vod_node(provider, content_id):
"""