mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-20 23:02:44 +02:00
Add discovery
This commit is contained in:
@@ -47,13 +47,31 @@ class RTLPlusDefaults:
|
||||
# HTTP settings
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
# GraphQL query parameters
|
||||
# GraphQL query parameters — live TV channels
|
||||
CHANNELS_QUERY_PARAMS = {
|
||||
"operationName": "LiveTvStations",
|
||||
"variables": '{"epgCount":4,"filter":{"channelTypes":["BROADCAST","FAST"]}}',
|
||||
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"845cf56a2a78110a0f978c1a2af2bc7f9a1c937d0f324ffaf852a9a4414c8485"}}',
|
||||
}
|
||||
|
||||
# GraphQL query parameters — editorial home view (contains LiveEventWidget nodes)
|
||||
EVENTS_QUERY_PARAMS = {
|
||||
"operationName": "ExploreWidgetWatch",
|
||||
"variables": '{"area":"home","offset":0,"take":15}',
|
||||
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"724f21ab86aa3f8c57673a3b346cf119f6a155bf82bb0201fd3b18e28e44f1ed"}}',
|
||||
}
|
||||
|
||||
# Custom header carrying the signed profile JWT (required by the events endpoint)
|
||||
PROFILE_HEADER = "Rtlplus-Profile"
|
||||
|
||||
# requiredPermission values returned by the events API
|
||||
PERMISSION_FREE_TV = "liveeventAccessToFreeTv"
|
||||
PERMISSION_PAY_TV = "liveeventAccessToPayTv"
|
||||
|
||||
# GraphQL __typename values used during response parsing
|
||||
TYPENAME_LIVE_EVENT_WIDGET = "LiveEventWidget"
|
||||
TYPENAME_LIVE_EVENT = "LiveEvent"
|
||||
|
||||
|
||||
class RTLPlusHeaders:
|
||||
"""Standard header configurations for RTL+ requests"""
|
||||
@@ -110,6 +128,45 @@ class RTLPlusHeaders:
|
||||
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def get_events_headers(
|
||||
access_token: str,
|
||||
profile_token: str = None,
|
||||
device_id: str = None,
|
||||
client_version: str = None,
|
||||
user_agent: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Get headers for the editorial GraphQL endpoint (events / ExploreWidgetWatch).
|
||||
|
||||
Extends the standard API headers with the optional ``Rtlplus-Profile``
|
||||
JWT that the browser sends when a user profile is active. The header
|
||||
is omitted when *profile_token* is ``None`` so that anonymous / client-
|
||||
credential sessions work without modification.
|
||||
|
||||
Args:
|
||||
access_token: Bearer token from the auth endpoint.
|
||||
profile_token: Signed profile JWT (``Rtlplus-Profile`` header).
|
||||
Pass ``None`` for anonymous / client-credential sessions.
|
||||
device_id: Device UUID forwarded as ``X-Device-Id``.
|
||||
client_version: Client version string; falls back to the default.
|
||||
user_agent: UA string; falls back to the default.
|
||||
|
||||
Returns:
|
||||
dict of HTTP headers ready for use with the GraphQL endpoint.
|
||||
"""
|
||||
headers = RTLPlusHeaders.get_api_headers(
|
||||
access_token=access_token,
|
||||
device_id=device_id,
|
||||
client_version=client_version,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
if profile_token:
|
||||
headers[RTLPlusDefaults.PROFILE_HEADER] = profile_token
|
||||
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def get_drm_headers(access_token: str, device_id: str = None, user_agent: str = None) -> dict:
|
||||
"""Get headers for Widevine/FairPlay DRM license requests"""
|
||||
@@ -183,6 +240,25 @@ class RTLPlusConfig:
|
||||
user_agent=self.user_agent,
|
||||
)
|
||||
|
||||
def get_events_headers(self, access_token: str, profile_token: str = None) -> dict:
|
||||
"""
|
||||
Get headers for the editorial GraphQL endpoint with this config's settings.
|
||||
|
||||
Args:
|
||||
access_token: Bearer token from the auth endpoint.
|
||||
profile_token: Optional signed profile JWT (``Rtlplus-Profile``).
|
||||
|
||||
Returns:
|
||||
dict of HTTP headers.
|
||||
"""
|
||||
return RTLPlusHeaders.get_events_headers(
|
||||
access_token=access_token,
|
||||
profile_token=profile_token,
|
||||
device_id=self.device_id,
|
||||
client_version=self.client_version,
|
||||
user_agent=self.user_agent,
|
||||
)
|
||||
|
||||
def get_drm_headers(self, access_token: str) -> dict:
|
||||
"""Get DRM headers with this config's settings"""
|
||||
return RTLPlusHeaders.get_drm_headers(
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from ...base.auth.base_auth import BaseAuthToken
|
||||
from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials
|
||||
from ...base.models import Event, ContentType
|
||||
from .constants import RTLPlusDefaults
|
||||
|
||||
|
||||
@@ -178,6 +179,73 @@ class RTLPlusChannel:
|
||||
"sort_order": self.sort_order,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class RTLPlusLiveEvent:
|
||||
"""
|
||||
RTL+ API representation of a live event before conversion to the
|
||||
domain Event model. Keeps parsing logic isolated from the domain layer.
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
stream_start: str # ISO string from API
|
||||
stream_end: str # ISO string from API
|
||||
location: Optional[str]
|
||||
event_category: str
|
||||
event_sub_category: str
|
||||
description: Optional[str]
|
||||
short_description: Optional[str]
|
||||
logo_url: Optional[str]
|
||||
required_permission: str
|
||||
watch_path: str
|
||||
|
||||
@classmethod
|
||||
def from_api_node(cls, node: Dict[str, Any]) -> "RTLPlusLiveEvent":
|
||||
"""Parse a single LiveEvent GraphQL node."""
|
||||
return cls(
|
||||
id=node["id"],
|
||||
title=node["title"],
|
||||
stream_start=node["streamStart"],
|
||||
stream_end=node["streamEnd"],
|
||||
location=node.get("location"),
|
||||
event_category=node.get("eventCategory", "GENERIC"),
|
||||
event_sub_category=node.get("eventSubCategory", ""),
|
||||
description=node.get("description"),
|
||||
short_description=node.get("shortDescription"),
|
||||
logo_url=(
|
||||
node.get("liveImages", {})
|
||||
.get("artworkLandscape", {})
|
||||
.get("url")
|
||||
),
|
||||
required_permission=node.get(
|
||||
"requiredPermission", RTLPlusDefaults.PERMISSION_PAY_TV
|
||||
),
|
||||
watch_path=node.get("urlData", {}).get("watchPath", ""),
|
||||
)
|
||||
|
||||
def to_event(self, provider: str = "rtlplus") -> "Event":
|
||||
"""Convert to the domain Event model."""
|
||||
from dateutil.parser import isoparse # already likely a dep; fallback below
|
||||
|
||||
start = isoparse(self.stream_start)
|
||||
end = isoparse(self.stream_end)
|
||||
|
||||
# Derive a stable content_id from the RRN (last segment after last colon)
|
||||
content_id = self.id.split(":")[-1]
|
||||
|
||||
return Event(
|
||||
name=self.title,
|
||||
content_id=content_id,
|
||||
provider=provider,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
logo_url=self.logo_url,
|
||||
description=self.description,
|
||||
genre=self.event_sub_category,
|
||||
content_type=ContentType.LIVE, # always live for RTL+ events
|
||||
language="de",
|
||||
country="DE",
|
||||
venue=self.location,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class RTLPlusStreamInfo:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# lib/streaming_providers/providers/rtlplus/provider.py
|
||||
import json
|
||||
import datetime
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
from typing import ClassVar, Dict, List, Optional, Any
|
||||
|
||||
import requests
|
||||
|
||||
@@ -11,6 +11,7 @@ from ...base.provider import StreamingProvider
|
||||
from ...base.utils import logger
|
||||
from .auth import RTLPlusAuthenticator
|
||||
from .constants import RTLPlusConfig, RTLPlusDefaults
|
||||
from .models import RTLPlusLiveEvent
|
||||
|
||||
|
||||
class RTLPlusProvider(StreamingProvider):
|
||||
@@ -164,7 +165,78 @@ class RTLPlusProvider(StreamingProvider):
|
||||
end_time: Optional[datetime] = None,
|
||||
**kwargs,
|
||||
) -> List[Event]:
|
||||
return []
|
||||
"""
|
||||
Fetch upcoming / live RTL+ events from the editorial GraphQL endpoint.
|
||||
|
||||
Filters by start_time / end_time if provided (both timezone-aware).
|
||||
"""
|
||||
raw_events = self._fetch_live_events()
|
||||
events: List[Event] = []
|
||||
|
||||
for raw in raw_events:
|
||||
try:
|
||||
event = raw.to_event(provider=self.provider_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"RTL+: Could not convert event '{raw.id}': {e}")
|
||||
continue
|
||||
|
||||
# Optional time-window filtering (delegate to caller semantics)
|
||||
if start_time and event.end_time and event.end_time < start_time:
|
||||
continue
|
||||
if end_time and event.start_time and event.start_time > end_time:
|
||||
continue
|
||||
|
||||
events.append(event)
|
||||
|
||||
logger.info(f"RTL+: Fetched {len(events)} events")
|
||||
return events
|
||||
|
||||
def _fetch_live_events(self) -> List[RTLPlusLiveEvent]:
|
||||
access_token = self.authenticator.get_bearer_token()
|
||||
headers = self.rtl_config.get_events_headers(access_token=access_token)
|
||||
|
||||
try:
|
||||
response = self.http_manager.get(
|
||||
self.rtl_config.graphql_endpoint,
|
||||
params=RTLPlusDefaults.EVENTS_QUERY_PARAMS,
|
||||
headers=headers,
|
||||
operation="api",
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(f"RTL+: Failed to fetch events: {e}")
|
||||
return []
|
||||
|
||||
return self._parse_live_events(response.json())
|
||||
|
||||
@staticmethod
|
||||
def _parse_live_events(data: Dict[str, Any]) -> List[RTLPlusLiveEvent]:
|
||||
"""
|
||||
Extract LiveEvent nodes from the GraphQL response.
|
||||
Static so it's unit-testable without provider state.
|
||||
"""
|
||||
events: List[RTLPlusLiveEvent] = []
|
||||
widgets = (
|
||||
data.get("data", {})
|
||||
.get("editorialView", {})
|
||||
.get("widgets", {})
|
||||
.get("items", [])
|
||||
)
|
||||
|
||||
for widget in widgets:
|
||||
if widget.get("__typename") != "LiveEventWidget":
|
||||
continue
|
||||
for element in widget.get("elements", []):
|
||||
if element is None:
|
||||
continue
|
||||
if element.get("__typename") != "LiveEvent":
|
||||
continue # skip ExternalTeaser etc.
|
||||
try:
|
||||
events.append(RTLPlusLiveEvent.from_api_node(element))
|
||||
except Exception as e:
|
||||
logger.warning(f"RTL+: Skipping malformed event node: {e}")
|
||||
|
||||
return events
|
||||
|
||||
def _parse_station_to_channel(self, station: Dict) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user