2026-04-28 18:54:26 +02:00
|
|
|
|
# streaming_providers/providers/rtlplus/event_manager.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
RTL+ Event Manager
|
|
|
|
|
|
|
|
|
|
|
|
Handles fetching future/live events from Bedrock layout API.
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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.
|
2026-05-17 20:30:28 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-05-16 22:15:18 +02:00
|
|
|
|
from datetime import datetime
|
2026-05-17 12:42:08 +02:00
|
|
|
|
from typing import List, Optional, Dict, Set, Tuple
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
from ...base.models.event import Event, EventStatus
|
|
|
|
|
|
from ...base.utils.logger import logger
|
|
|
|
|
|
from ...base.models import DRMConfig
|
2026-05-08 09:54:08 +02:00
|
|
|
|
from .layout_helpers import unwrap_target, parse_german_datetime, extract_thumbnail
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RTLPlusEventManager:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Manages fetching events (live streams, upcoming sports) for RTL+.
|
|
|
|
|
|
|
|
|
|
|
|
Uses the Bedrock layout API to fetch folder pages and paginated blocks.
|
2026-05-08 09:54:08 +02:00
|
|
|
|
Pagination follows the nextPage values exactly as returned by the API,
|
|
|
|
|
|
which may be non-sequential (1 → 3 → 6 → ...).
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# Block titles that indicate live stream content
|
|
|
|
|
|
LIVE_STREAM_BLOCK_TITLES = [
|
2026-05-08 09:54:08 +02:00
|
|
|
|
"Diese Live-Events erwarten euch", # Primary block on homepage
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"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(
|
2026-05-10 22:05:34 +02:00
|
|
|
|
self,
|
|
|
|
|
|
folder_id: str = "6",
|
|
|
|
|
|
start_time: Optional[datetime] = None,
|
|
|
|
|
|
end_time: Optional[datetime] = None,
|
|
|
|
|
|
force_refresh: bool = False,
|
|
|
|
|
|
max_pages: int = 20,
|
2026-04-28 18:54:26 +02:00
|
|
|
|
) -> List[Event]:
|
|
|
|
|
|
"""
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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.
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-10 22:05:34 +02:00
|
|
|
|
Returns empty list if no events are found.
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
all_events: List[Event] = []
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# PRIMARY: Fetch from homepage (has all events in one block)
|
2026-05-10 22:05:34 +02:00
|
|
|
|
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}")
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Filter by time range
|
2026-04-28 18:54:26 +02:00
|
|
|
|
filtered_events = self._filter_by_time(all_events, start_time, end_time)
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Sort chronologically
|
2026-04-28 18:54:26 +02:00
|
|
|
|
filtered_events.sort(key=lambda e: e.start_time if e.start_time else datetime.max)
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
logger.info(f"Returning {len(filtered_events)} events after filtering")
|
2026-04-28 18:54:26 +02:00
|
|
|
|
return filtered_events
|
|
|
|
|
|
|
2026-06-06 22:48:16 +02:00
|
|
|
|
def get_manifest_for_event(self, event_id: str, drm_variant: str = "auto") -> Optional[str]:
|
2026-05-17 20:30:28 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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).
|
2026-06-06 22:48:16 +02:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
event_id: Folder ID for the event
|
|
|
|
|
|
drm_variant: 'auto', 'software', or 'hardware'
|
2026-05-17 20:30:28 +02:00
|
|
|
|
"""
|
|
|
|
|
|
# --- 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 -----------------------------------------
|
2026-06-06 22:48:16 +02:00
|
|
|
|
manifest = self._manifest_from_live_layout(live_id, live_seo, drm_variant=drm_variant)
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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:
|
2026-06-06 22:48:16 +02:00
|
|
|
|
manifest = self._provider.extract_best_manifest_url(assets, drm_variant=drm_variant)
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-06 22:48:16 +02:00
|
|
|
|
def get_drm_for_event(self, event_id: str, drm_variant: str = "auto") -> List[DRMConfig]:
|
2026-05-17 20:30:28 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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).
|
2026-06-06 22:48:16 +02:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
event_id: Folder ID for the event
|
|
|
|
|
|
drm_variant: 'auto', 'software', or 'hardware'
|
2026-05-17 20:30:28 +02:00
|
|
|
|
"""
|
|
|
|
|
|
# --- 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 -----------------------------------------
|
2026-06-06 22:48:16 +02:00
|
|
|
|
drm_configs = self._drm_from_live_layout(live_id, live_seo, drm_variant=drm_variant)
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
2026-06-06 22:48:16 +02:00
|
|
|
|
drm_configs = self._provider.get_drm_for_content(folder_layout, drm_variant=drm_variant)
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-17 14:32:53 +02:00
|
|
|
|
@staticmethod
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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", []):
|
2026-05-17 14:32:53 +02:00
|
|
|
|
if block.get("type") != "bffPaginated":
|
|
|
|
|
|
continue
|
2026-05-17 20:30:28 +02:00
|
|
|
|
|
2026-05-17 14:32:53 +02:00
|
|
|
|
for item in block.get("content", {}).get("items", []):
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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", {}))
|
2026-05-17 14:32:53 +02:00
|
|
|
|
value_layout = target.get("value_layout", {})
|
2026-05-17 20:30:28 +02:00
|
|
|
|
|
2026-05-17 14:32:53 +02:00
|
|
|
|
if value_layout.get("type") == "live":
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
2026-05-17 13:20:17 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
return None, None
|
2026-05-17 13:20:17 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
def _manifest_from_live_layout(
|
|
|
|
|
|
self,
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
channel_seo: Optional[str] = None,
|
2026-06-06 22:48:16 +02:00
|
|
|
|
drm_variant: str = "auto",
|
2026-05-17 20:30:28 +02:00
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
|
path_id = (
|
2026-05-17 20:45:16 +02:00
|
|
|
|
channel_seo
|
|
|
|
|
|
or self._provider.channel_manager._normalize_channel_identifier(channel_id)
|
2026-05-17 14:32:53 +02:00
|
|
|
|
)
|
2026-05-17 20:30:28 +02:00
|
|
|
|
location = f"{self.cfg.base_website}{path_id}/live"
|
2026-05-17 13:20:17 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
2026-05-17 13:20:17 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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
|
2026-05-17 13:20:17 +02:00
|
|
|
|
|
2026-06-06 22:48:16 +02:00
|
|
|
|
return self._provider.extract_best_manifest_url(assets, drm_variant=drm_variant)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
def _drm_from_live_layout(
|
|
|
|
|
|
self,
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
channel_seo: Optional[str] = None,
|
2026-06-06 22:48:16 +02:00
|
|
|
|
drm_variant: str = "auto",
|
2026-05-17 20:30:28 +02:00
|
|
|
|
) -> List[DRMConfig]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Step 2 for DRM: fetch the live layout and extract DRM configuration.
|
2026-05-17 12:42:08 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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"
|
2026-05-17 12:42:08 +02:00
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
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 []
|
|
|
|
|
|
|
2026-06-06 22:48:16 +02:00
|
|
|
|
return self._provider.get_drm_for_content(live_layout, drm_variant=drm_variant)
|
2026-05-17 12:42:08 +02:00
|
|
|
|
|
2026-04-28 18:54:26 +02:00
|
|
|
|
# --------------------------------------------------------------------------
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Homepage Event Fetching (Primary Source)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
def _fetch_events_from_homepage(
|
|
|
|
|
|
self, force_refresh: bool = False, max_pages: int = 20
|
|
|
|
|
|
) -> List[Event]:
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"""
|
2026-05-08 09:54:08 +02:00
|
|
|
|
Fetch events from the homepage's "Diese Live-Events erwarten euch" block.
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
This is the primary source because it contains ALL live events in one
|
|
|
|
|
|
paginated block, unlike folder views which only show one event.
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
if not home_layout:
|
|
|
|
|
|
logger.warning("Failed to fetch home layout for events")
|
|
|
|
|
|
return []
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
live_block = self._find_live_events_block(home_layout)
|
|
|
|
|
|
if not live_block:
|
|
|
|
|
|
logger.debug("No live events block found on homepage")
|
|
|
|
|
|
return []
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
block_id = live_block.get("id")
|
2026-04-28 18:54:26 +02:00
|
|
|
|
if not block_id:
|
2026-05-08 09:54:08 +02:00
|
|
|
|
logger.warning("Live events block missing 'id' field")
|
2026-04-28 18:54:26 +02:00
|
|
|
|
return []
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-17 20:30:28 +02:00
|
|
|
|
logger.debug(
|
|
|
|
|
|
f"Live events block: total={pagination.get('totalItems', 0)}, next_page={next_page}"
|
|
|
|
|
|
)
|
2026-05-08 09:54:08 +02:00
|
|
|
|
|
|
|
|
|
|
# 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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-28 18:54:26 +02:00
|
|
|
|
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", [])
|
2026-05-08 09:54:08 +02:00
|
|
|
|
all_events.extend(self._extract_events_from_items(items))
|
|
|
|
|
|
pages_fetched += 1
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
pagination = page_data.get("content", {}).get("pagination", {})
|
|
|
|
|
|
next_page = pagination.get("nextPage")
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-10 22:05:34 +02:00
|
|
|
|
# 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
|
2026-05-08 09:54:08 +02:00
|
|
|
|
|
|
|
|
|
|
# Exact match — return immediately
|
2026-05-16 22:15:18 +02:00
|
|
|
|
if block_title.strip() == "Diese Live-Events erwarten euch":
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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))
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
return all_events
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# Event Extraction from Items
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-28 18:54:26 +02:00
|
|
|
|
def _extract_events_from_items(self, items: List[Dict]) -> List[Event]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Extract Event objects from block items.
|
2026-05-08 09:54:08 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Parse the date from highlight, falling back to description
|
|
|
|
|
|
event_date = parse_german_datetime(highlight)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
if not event_date:
|
|
|
|
|
|
description = item_content.get("description", "")
|
|
|
|
|
|
if description:
|
2026-05-08 09:54:08 +02:00
|
|
|
|
event_date = parse_german_datetime(description)
|
|
|
|
|
|
if not event_date:
|
2026-04-28 18:54:26 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-05-14 12:17:52 +02:00
|
|
|
|
# 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:
|
2026-04-28 18:54:26 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Extract title
|
|
|
|
|
|
title = item_content.get("title") or self._extract_title_from_highlight(highlight)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
if not title:
|
|
|
|
|
|
title = "Unknown Event"
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# 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")
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
if not folder_id:
|
|
|
|
|
|
logger.debug(f"Skipping event with no folder_id: {title}")
|
|
|
|
|
|
continue
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
sport = self._extract_sport_type(highlight)
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
event = Event(
|
|
|
|
|
|
name=title,
|
2026-05-08 09:54:08 +02:00
|
|
|
|
content_id=folder_id,
|
2026-04-28 18:54:26 +02:00
|
|
|
|
provider=self._provider.provider_name,
|
|
|
|
|
|
start_time=event_date,
|
|
|
|
|
|
end_time=None, # End time not provided in this response
|
|
|
|
|
|
status=status,
|
2026-05-08 09:54:08 +02:00
|
|
|
|
logo_url=extract_thumbnail(item_content),
|
2026-04-28 18:54:26 +02:00
|
|
|
|
genre=sport,
|
|
|
|
|
|
venue=item_content.get("details") or item_content.get("extraDetails"),
|
|
|
|
|
|
description=item_content.get("description"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# Store additional metadata for later manifest resolution
|
|
|
|
|
|
event.manifest_script = json.dumps({
|
|
|
|
|
|
"folder_id": folder_id,
|
|
|
|
|
|
"seo": value_layout.get("seo"),
|
2026-04-28 18:54:26 +02:00
|
|
|
|
"highlight": highlight,
|
|
|
|
|
|
"title": title,
|
2026-05-08 09:54:08 +02:00
|
|
|
|
})
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
events.append(event)
|
|
|
|
|
|
|
|
|
|
|
|
return events
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# Date / Status / Sport Helpers
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-08 09:54:08 +02:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-04-28 18:54:26 +02:00
|
|
|
|
now = datetime.now()
|
2026-05-08 09:54:08 +02:00
|
|
|
|
|
|
|
|
|
|
# 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
|
2026-05-16 22:15:18 +02:00
|
|
|
|
if event_date <= now:
|
2026-05-08 09:54:08 +02:00
|
|
|
|
return EventStatus.LIVE
|
|
|
|
|
|
|
|
|
|
|
|
if event_date > now:
|
|
|
|
|
|
return EventStatus.SCHEDULED
|
|
|
|
|
|
|
|
|
|
|
|
return EventStatus.ENDED
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_title_from_highlight(highlight: str) -> str:
|
|
|
|
|
|
"""Extract event title from highlight text when no separate title exists."""
|
2026-05-08 09:54:08 +02:00
|
|
|
|
import re
|
2026-04-28 18:54:26 +02:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-05-08 09:54:08 +02:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# Time Filtering
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
2026-04-28 18:54:26 +02:00
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-05-08 09:54:08 +02:00
|
|
|
|
if start_time and event.start_time and event.start_time < start_time:
|
2026-04-28 18:54:26 +02:00
|
|
|
|
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:
|
2026-05-08 09:54:08 +02:00
|
|
|
|
cache_key = f"folder:{folder_id}:1:2"
|
2026-04-28 18:54:26 +02:00
|
|
|
|
self._provider.invalidate_layout_cache(cache_key)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._provider.invalidate_layout_cache()
|