mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-23 09:32:22 +02:00
628 lines
24 KiB
Python
628 lines
24 KiB
Python
# streaming_providers/providers/rtlplus/event_manager.py
|
||
"""
|
||
RTL+ Event Manager
|
||
|
||
Handles fetching future/live events from Bedrock layout API.
|
||
Uses the homepage's "Diese Live-Events erwarten euch" block as primary source.
|
||
Follows non-sequential pagination (1 → 3 → 6 → ...) as returned by the API.
|
||
|
||
Two-step manifest/DRM resolution for live events:
|
||
Step 1: Fetch folder layout → find the live channel redirect (e.g. "rtlde_nitro" / "nitro")
|
||
Step 2: Fetch live layout → extract video assets → pick best manifest / DRM config
|
||
"""
|
||
|
||
import json
|
||
from datetime import datetime
|
||
from typing import List, Optional, Dict, Set, Tuple
|
||
|
||
from ...base.models.event import Event, EventStatus
|
||
from ...base.utils.logger import logger
|
||
from ...base.models import DRMConfig
|
||
from .layout_helpers import unwrap_target, parse_german_datetime, extract_thumbnail
|
||
|
||
|
||
class RTLPlusEventManager:
|
||
"""
|
||
Manages fetching events (live streams, upcoming sports) for RTL+.
|
||
|
||
Uses the Bedrock layout API to fetch folder pages and paginated blocks.
|
||
Pagination follows the nextPage values exactly as returned by the API,
|
||
which may be non-sequential (1 → 3 → 6 → ...).
|
||
"""
|
||
|
||
# Block titles that indicate live stream content
|
||
LIVE_STREAM_BLOCK_TITLES = [
|
||
"Diese Live-Events erwarten euch", # Primary block on homepage
|
||
"Sport im Live-Stream",
|
||
"UEFA Europa & Conference League | Live",
|
||
"Live-Stream",
|
||
"Live Events",
|
||
]
|
||
|
||
# Sport type mapping
|
||
SPORT_TYPES = {
|
||
"Fußball": "Football",
|
||
"Motorsport": "Motorsports",
|
||
"MMA": "MMA",
|
||
"NFL": "American Football",
|
||
"Europa League": "Football",
|
||
"Conference League": "Football",
|
||
"Bundesliga": "Football",
|
||
}
|
||
|
||
def __init__(self, provider):
|
||
self._provider = provider
|
||
|
||
@property
|
||
def cfg(self):
|
||
return self._provider.rtl_config
|
||
|
||
@property
|
||
def http(self):
|
||
return self._provider.http_manager
|
||
|
||
@property
|
||
def auth(self):
|
||
return self._provider.authenticator
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Public API
|
||
# --------------------------------------------------------------------------
|
||
|
||
def get_events(
|
||
self,
|
||
folder_id: str = "6",
|
||
start_time: Optional[datetime] = None,
|
||
end_time: Optional[datetime] = None,
|
||
force_refresh: bool = False,
|
||
max_pages: int = 20,
|
||
) -> List[Event]:
|
||
"""
|
||
Fetch all future/live events.
|
||
|
||
Primary source is the homepage's "Diese Live-Events erwarten euch" block.
|
||
Falls back to specific folder if homepage fails.
|
||
|
||
Returns empty list if no events are found.
|
||
"""
|
||
all_events: List[Event] = []
|
||
|
||
# PRIMARY: Fetch from homepage (has all events in one block)
|
||
try:
|
||
home_events = self._fetch_events_from_homepage(force_refresh, max_pages)
|
||
if home_events:
|
||
all_events.extend(home_events)
|
||
logger.info(f"Fetched {len(home_events)} events from homepage")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch events from homepage: {e}")
|
||
|
||
# If no events from homepage, try fallback
|
||
if not all_events:
|
||
try:
|
||
logger.debug(f"Falling back to folder {folder_id} for events")
|
||
layout = self._provider.fetch_layout(
|
||
layout_type="folder",
|
||
content_id=folder_id,
|
||
force_refresh=force_refresh,
|
||
)
|
||
if layout:
|
||
folder_events = self._extract_events_from_layout(layout)
|
||
all_events.extend(folder_events)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch events from folder {folder_id}: {e}")
|
||
|
||
# Filter by time range
|
||
filtered_events = self._filter_by_time(all_events, start_time, end_time)
|
||
|
||
# Sort chronologically
|
||
filtered_events.sort(key=lambda e: e.start_time if e.start_time else datetime.max)
|
||
|
||
logger.info(f"Returning {len(filtered_events)} events after filtering")
|
||
return filtered_events
|
||
|
||
def get_manifest_for_event(self, event_id: str, drm_variant: str = "auto") -> Optional[str]:
|
||
"""
|
||
Get manifest URL for an event using the two-step flow:
|
||
|
||
Step 1: Fetch the folder layout for ``event_id`` and locate the live
|
||
channel redirect embedded in the "Jetzt live" block
|
||
(value_layout.type == "live").
|
||
Step 2: Fetch the live layout for that channel and extract the best
|
||
manifest URL from its video assets.
|
||
|
||
Falls back to direct asset extraction from the folder itself when no
|
||
live redirect is present (handles VOD-style event folders).
|
||
|
||
Args:
|
||
event_id: Folder ID for the event
|
||
drm_variant: 'auto', 'software', or 'hardware'
|
||
"""
|
||
# --- Step 1: folder layout -------------------------------------------
|
||
folder_layout = self._provider.fetch_layout(
|
||
layout_type="folder",
|
||
content_id=event_id,
|
||
)
|
||
if not folder_layout:
|
||
logger.error(f"get_manifest_for_event: could not fetch folder layout for {event_id}")
|
||
return None
|
||
|
||
live_id, live_seo = self._extract_live_redirect(folder_layout)
|
||
|
||
if live_id:
|
||
logger.debug(
|
||
f"Event {event_id} -> live redirect: id={live_id}, seo={live_seo}"
|
||
)
|
||
# --- Step 2: live layout -----------------------------------------
|
||
manifest = self._manifest_from_live_layout(live_id, live_seo, drm_variant=drm_variant)
|
||
if manifest:
|
||
return manifest
|
||
logger.warning(
|
||
f"Event {event_id}: live layout for '{live_seo or live_id}' returned no manifest"
|
||
)
|
||
|
||
# Fallback: the folder itself may carry video assets (rare but possible)
|
||
assets = self._provider.extract_video_assets(folder_layout)
|
||
if assets:
|
||
manifest = self._provider.extract_best_manifest_url(assets, drm_variant=drm_variant)
|
||
if manifest:
|
||
logger.debug(f"Event {event_id}: manifest resolved from folder assets")
|
||
return manifest
|
||
|
||
logger.error(f"get_manifest_for_event: no manifest found for event {event_id}")
|
||
return None
|
||
|
||
def get_drm_for_event(self, event_id: str, drm_variant: str = "auto") -> List[DRMConfig]:
|
||
"""
|
||
Get DRM configuration for an event using the two-step flow:
|
||
|
||
Step 1: Fetch the folder layout for ``event_id`` and locate the live
|
||
channel redirect embedded in the "Jetzt live" block
|
||
(value_layout.type == "live").
|
||
Step 2: Fetch the live layout for that channel and extract DRM config
|
||
from its video assets.
|
||
|
||
Falls back to direct DRM extraction from the folder layout when no live
|
||
redirect is found (handles VOD-style event folders).
|
||
|
||
Args:
|
||
event_id: Folder ID for the event
|
||
drm_variant: 'auto', 'software', or 'hardware'
|
||
"""
|
||
# --- Step 1: folder layout -------------------------------------------
|
||
folder_layout = self._provider.fetch_layout(
|
||
layout_type="folder",
|
||
content_id=event_id,
|
||
)
|
||
if not folder_layout:
|
||
logger.error(f"get_drm_for_event: could not fetch folder layout for {event_id}")
|
||
return []
|
||
|
||
live_id, live_seo = self._extract_live_redirect(folder_layout)
|
||
|
||
if live_id:
|
||
logger.debug(
|
||
f"Event {event_id} -> live redirect: id={live_id}, seo={live_seo}"
|
||
)
|
||
# --- Step 2: live layout -----------------------------------------
|
||
drm_configs = self._drm_from_live_layout(live_id, live_seo, drm_variant=drm_variant)
|
||
if drm_configs:
|
||
return drm_configs
|
||
logger.warning(
|
||
f"Event {event_id}: live layout for '{live_seo or live_id}' returned no DRM"
|
||
)
|
||
|
||
# Fallback: try extracting DRM directly from the folder layout
|
||
drm_configs = self._provider.get_drm_for_content(folder_layout, drm_variant=drm_variant)
|
||
if drm_configs:
|
||
logger.debug(f"Event {event_id}: DRM resolved from folder assets")
|
||
return drm_configs
|
||
|
||
logger.error(f"get_drm_for_event: no DRM config found for event {event_id}")
|
||
return []
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Two-step helpers
|
||
# --------------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _extract_live_redirect(folder_layout: Dict) -> Tuple[Optional[str], Optional[str]]:
|
||
"""
|
||
Scan a folder layout and return the first live-channel redirect found.
|
||
|
||
Returns ``(channel_id, channel_seo)`` – e.g. ``("rtlde_nitro", "nitro")``.
|
||
Both values are ``None`` when no live redirect is present.
|
||
|
||
The redirect lives in bffPaginated blocks as an item whose action target
|
||
has ``value_layout.type == "live"``. Both "classic" items (the "Jetzt
|
||
live" programme card shown in the folder) and "video" items (the inline
|
||
player block) are checked so the method works for both folder shapes
|
||
encountered in the wild.
|
||
"""
|
||
for block in folder_layout.get("blocks", []):
|
||
if block.get("type") != "bffPaginated":
|
||
continue
|
||
|
||
for item in block.get("content", {}).get("items", []):
|
||
item_content = item.get("itemContent", {})
|
||
|
||
# Both classic and video items carry the redirect in action.target
|
||
action = item_content.get("action", {})
|
||
target = unwrap_target(action.get("target", {}))
|
||
value_layout = target.get("value_layout", {})
|
||
|
||
if value_layout.get("type") == "live":
|
||
channel_id = value_layout.get("id") # e.g. "rtlde_nitro"
|
||
channel_seo = value_layout.get("seo") # e.g. "nitro"
|
||
if channel_id:
|
||
return channel_id, channel_seo
|
||
|
||
return None, None
|
||
|
||
def _manifest_from_live_layout(
|
||
self,
|
||
channel_id: str,
|
||
channel_seo: Optional[str] = None,
|
||
drm_variant: str = "auto",
|
||
) -> Optional[str]:
|
||
path_id = (
|
||
channel_seo
|
||
or self._provider.channel_manager._normalize_channel_identifier(channel_id)
|
||
)
|
||
location = f"{self.cfg.base_website}{path_id}/live"
|
||
|
||
live_layout = self._provider.fetch_layout(
|
||
layout_type="live",
|
||
content_id=path_id,
|
||
location=location,
|
||
)
|
||
if not live_layout:
|
||
logger.warning(f"_manifest_from_live_layout: no live layout returned for '{path_id}'")
|
||
return None
|
||
|
||
assets = self._provider.extract_video_assets(live_layout)
|
||
if not assets:
|
||
logger.warning(
|
||
f"_manifest_from_live_layout: no video assets in live layout for '{path_id}'"
|
||
)
|
||
return None
|
||
|
||
return self._provider.extract_best_manifest_url(assets, drm_variant=drm_variant)
|
||
|
||
def _drm_from_live_layout(
|
||
self,
|
||
channel_id: str,
|
||
channel_seo: Optional[str] = None,
|
||
drm_variant: str = "auto",
|
||
) -> List[DRMConfig]:
|
||
"""
|
||
Step 2 for DRM: fetch the live layout and extract DRM configuration.
|
||
|
||
Uses the same SEO-slug normalisation as ``_manifest_from_live_layout``.
|
||
"""
|
||
path_id = (
|
||
channel_seo
|
||
or self._provider.channel_manager._normalize_channel_identifier(channel_id)
|
||
)
|
||
location = f"{self.cfg.base_website}{path_id}/live"
|
||
|
||
live_layout = self._provider.fetch_layout(
|
||
layout_type="live",
|
||
content_id=path_id,
|
||
location=location,
|
||
)
|
||
if not live_layout:
|
||
logger.warning(f"_drm_from_live_layout: no live layout returned for '{path_id}'")
|
||
return []
|
||
|
||
return self._provider.get_drm_for_content(live_layout, drm_variant=drm_variant)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Homepage Event Fetching (Primary Source)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _fetch_events_from_homepage(
|
||
self, force_refresh: bool = False, max_pages: int = 20
|
||
) -> List[Event]:
|
||
"""
|
||
Fetch events from the homepage's "Diese Live-Events erwarten euch" block.
|
||
|
||
This is the primary source because it contains ALL live events in one
|
||
paginated block, unlike folder views which only show one event.
|
||
|
||
Pagination follows the nextPage values exactly as returned by the API.
|
||
The API uses non-sequential page numbers (1 → 3 → 6 → ...).
|
||
"""
|
||
home_layout = self._provider.fetch_layout(
|
||
layout_type="alias",
|
||
content_id="home",
|
||
location=f"{self._provider.rtl_config.base_website}",
|
||
force_refresh=force_refresh,
|
||
)
|
||
|
||
if not home_layout:
|
||
logger.warning("Failed to fetch home layout for events")
|
||
return []
|
||
|
||
live_block = self._find_live_events_block(home_layout)
|
||
if not live_block:
|
||
logger.debug("No live events block found on homepage")
|
||
return []
|
||
|
||
block_id = live_block.get("id")
|
||
if not block_id:
|
||
logger.warning("Live events block missing 'id' field")
|
||
return []
|
||
|
||
all_events: List[Event] = []
|
||
# visited_pages guards against an infinite loop if the API ever returns
|
||
# a nextPage value that points back to a page we already fetched.
|
||
visited_pages: Set[int] = set()
|
||
|
||
# Process first page (already loaded in home_layout)
|
||
initial_items = live_block.get("content", {}).get("items", [])
|
||
all_events.extend(self._extract_events_from_items(initial_items))
|
||
visited_pages.add(1)
|
||
|
||
# Get pagination info from first page
|
||
pagination = live_block.get("content", {}).get("pagination", {})
|
||
next_page = pagination.get("nextPage") # Will be 3, not 2!
|
||
pages_fetched = 1
|
||
|
||
logger.debug(
|
||
f"Live events block: total={pagination.get('totalItems', 0)}, next_page={next_page}"
|
||
)
|
||
|
||
# Follow nextPage links exactly as returned by API.
|
||
# The API skips many page numbers (e.g. 1 → 3 → 6 → 9), so we must
|
||
# never assume the next page number — always use the value from the
|
||
# pagination response.
|
||
while next_page and next_page not in visited_pages and pages_fetched < max_pages:
|
||
visited_pages.add(next_page)
|
||
logger.debug(f"Fetching page {next_page} of live events block")
|
||
|
||
page_data = self._provider.fetch_block_page(
|
||
block_id=block_id,
|
||
page=next_page,
|
||
nb_pages=3,
|
||
service_id="rtlplus_root",
|
||
)
|
||
|
||
if not page_data:
|
||
logger.warning(f"Failed to fetch page {next_page} for block {block_id}")
|
||
break
|
||
|
||
items = page_data.get("content", {}).get("items", [])
|
||
all_events.extend(self._extract_events_from_items(items))
|
||
pages_fetched += 1
|
||
|
||
pagination = page_data.get("content", {}).get("pagination", {})
|
||
next_page = pagination.get("nextPage")
|
||
|
||
logger.info(f"Fetched {len(all_events)} events from {pages_fetched} pages")
|
||
return all_events
|
||
|
||
def _find_live_events_block(self, layout: Dict) -> Optional[Dict]:
|
||
"""
|
||
Find the live events block in homepage layout.
|
||
|
||
Prefers an exact match on "Diese Live-Events erwarten euch", then
|
||
falls back to any block whose title contains a known pattern.
|
||
"""
|
||
fallback: Optional[Dict] = None
|
||
|
||
for block in layout.get("blocks", []):
|
||
if block.get("type") != "bffPaginated":
|
||
continue
|
||
|
||
# Get block_title safely - it might be None
|
||
block_title = None
|
||
analytics = block.get("analytics", {})
|
||
if analytics:
|
||
tealium = analytics.get("tealium", {})
|
||
if tealium:
|
||
block_title = tealium.get("block_title")
|
||
|
||
if block_title is None:
|
||
continue
|
||
|
||
# Exact match — return immediately
|
||
if block_title.strip() == "Diese Live-Events erwarten euch":
|
||
logger.debug(f"Found live events block (exact): '{block_title}'")
|
||
return block
|
||
|
||
# Partial match — keep as fallback, continue looking for exact
|
||
if fallback is None:
|
||
for pattern in self.LIVE_STREAM_BLOCK_TITLES:
|
||
if pattern.lower() in block_title.lower():
|
||
logger.debug(f"Found potential live block (partial): '{block_title}'")
|
||
fallback = block
|
||
break
|
||
|
||
return fallback
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Layout Extraction Helpers
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _extract_events_from_layout(self, layout: Dict) -> List[Event]:
|
||
"""Extract events from any layout by scanning all bffPaginated blocks."""
|
||
all_events: List[Event] = []
|
||
|
||
for block in layout.get("blocks", []):
|
||
if block.get("type") != "bffPaginated":
|
||
continue
|
||
|
||
items = block.get("content", {}).get("items", [])
|
||
all_events.extend(self._extract_events_from_items(items))
|
||
|
||
return all_events
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Event Extraction from Items
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _extract_events_from_items(self, items: List[Dict]) -> List[Event]:
|
||
"""
|
||
Extract Event objects from block items.
|
||
|
||
Each item represents a live event with:
|
||
- highlight: "Fußball • Do., 07.05.26, 20:30 Uhr"
|
||
- action.target.value_layout: contains folder_id for the event
|
||
"""
|
||
events: List[Event] = []
|
||
|
||
for item in items:
|
||
if item.get("itemType") != "classic":
|
||
continue
|
||
|
||
item_content = item.get("itemContent", {})
|
||
highlight = item_content.get("highlight", "")
|
||
|
||
if not highlight:
|
||
continue
|
||
|
||
# Parse the date from highlight, falling back to description
|
||
event_date = parse_german_datetime(highlight)
|
||
if not event_date:
|
||
description = item_content.get("description", "")
|
||
if description:
|
||
event_date = parse_german_datetime(description)
|
||
if not event_date:
|
||
continue
|
||
|
||
# Determine status early so we can decide whether to keep the event
|
||
status = self._determine_event_status(event_date, highlight)
|
||
|
||
# Exclude events that have already ended (not live and started > 3 h ago)
|
||
if status == EventStatus.ENDED:
|
||
continue
|
||
|
||
# Extract title
|
||
title = item_content.get("title") or self._extract_title_from_highlight(highlight)
|
||
if not title:
|
||
title = "Unknown Event"
|
||
|
||
# Unwrap the action target (handles lock-wrapped targets transparently)
|
||
action = item_content.get("action", {})
|
||
target = unwrap_target(action.get("target", {}))
|
||
value_layout = target.get("value_layout", {})
|
||
folder_id = value_layout.get("id")
|
||
|
||
# Fallback to itemContent.id if folder_id not found
|
||
if not folder_id:
|
||
folder_id = item_content.get("id")
|
||
|
||
if not folder_id:
|
||
logger.debug(f"Skipping event with no folder_id: {title}")
|
||
continue
|
||
|
||
sport = self._extract_sport_type(highlight)
|
||
|
||
event = Event(
|
||
name=title,
|
||
content_id=folder_id,
|
||
provider=self._provider.provider_name,
|
||
start_time=event_date,
|
||
end_time=None, # End time not provided in this response
|
||
status=status,
|
||
logo_url=extract_thumbnail(item_content),
|
||
genre=sport,
|
||
venue=item_content.get("details") or item_content.get("extraDetails"),
|
||
description=item_content.get("description"),
|
||
)
|
||
|
||
# Store additional metadata for later manifest resolution
|
||
event.manifest_script = json.dumps({
|
||
"folder_id": folder_id,
|
||
"seo": value_layout.get("seo"),
|
||
"highlight": highlight,
|
||
"title": title,
|
||
})
|
||
|
||
events.append(event)
|
||
|
||
return events
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Date / Status / Sport Helpers
|
||
# --------------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _determine_event_status(event_date: datetime, highlight: str) -> EventStatus:
|
||
"""
|
||
Determine if event is live, upcoming, or ended.
|
||
|
||
Uses both the date/time and the presence of "Live" in the highlight text.
|
||
"""
|
||
now = datetime.now()
|
||
|
||
# Explicit "Live" label in the metadata takes priority
|
||
if "live" in highlight.lower():
|
||
return EventStatus.LIVE
|
||
|
||
# Event started within the last 3 hours → treat as live
|
||
if event_date <= now:
|
||
return EventStatus.LIVE
|
||
|
||
if event_date > now:
|
||
return EventStatus.SCHEDULED
|
||
|
||
return EventStatus.ENDED
|
||
|
||
@staticmethod
|
||
def _extract_title_from_highlight(highlight: str) -> str:
|
||
"""Extract event title from highlight text when no separate title exists."""
|
||
import re
|
||
parts = highlight.split("•")
|
||
if len(parts) >= 2:
|
||
middle = parts[1].strip()
|
||
date_match = re.search(r"\d{2}\.\d{2}\.\d{2}", middle)
|
||
if date_match:
|
||
title = middle[:date_match.start()].strip()
|
||
if title:
|
||
return title
|
||
return highlight
|
||
|
||
def _extract_sport_type(self, highlight: str) -> str:
|
||
"""Extract sport type from highlight."""
|
||
sport_part = highlight.split("•")[0].strip()
|
||
for german, english in self.SPORT_TYPES.items():
|
||
if german in sport_part:
|
||
return english
|
||
return sport_part or "Sport"
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Time Filtering
|
||
# --------------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _filter_by_time(
|
||
events: List[Event],
|
||
start_time: Optional[datetime],
|
||
end_time: Optional[datetime],
|
||
) -> List[Event]:
|
||
"""Filter events by time range."""
|
||
if not start_time and not end_time:
|
||
return events
|
||
|
||
filtered = []
|
||
for event in events:
|
||
if start_time and event.start_time and event.start_time < start_time:
|
||
continue
|
||
if end_time and event.start_time and event.start_time > end_time:
|
||
continue
|
||
filtered.append(event)
|
||
|
||
return filtered
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Cache Management
|
||
# --------------------------------------------------------------------------
|
||
|
||
def invalidate_cache(self, folder_id: Optional[str] = None):
|
||
"""Invalidate the layout cache."""
|
||
if folder_id:
|
||
cache_key = f"folder:{folder_id}:1:2"
|
||
self._provider.invalidate_layout_cache(cache_key)
|
||
else:
|
||
self._provider.invalidate_layout_cache() |