mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-30 04:52:38 +02:00
Add RTL search VOD
This commit is contained in:
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user