mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-26 02:52:45 +02:00
482 lines
17 KiB
Python
482 lines
17 KiB
Python
# streaming_providers/providers/rtlplus/event_manager.py
|
|
"""
|
|
RTL+ Event Manager
|
|
|
|
Handles fetching future/live events from Bedrock layout API.
|
|
Replaces legacy GraphQL-based event fetching.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict
|
|
|
|
from ...base.models.event import Event, EventStatus
|
|
from ...base.utils.logger import logger
|
|
from ...base.models import DRMConfig
|
|
|
|
|
|
class RTLPlusEventManager:
|
|
"""
|
|
Manages fetching events (live streams, upcoming sports) for RTL+.
|
|
|
|
Uses the Bedrock layout API to fetch folder pages and paginated blocks.
|
|
"""
|
|
|
|
# Block titles that indicate live stream content
|
|
LIVE_STREAM_BLOCK_TITLES = [
|
|
"Sport im Live-Stream",
|
|
"UEFA Europa & Conference League | Live",
|
|
"Live-Stream",
|
|
"Live Events",
|
|
]
|
|
|
|
# Date parsing patterns (German)
|
|
DATE_PATTERNS = [
|
|
r"(\d{2})\.(\d{2})\.(\d{2}),\s*(\d{2}):(\d{2})\s*Uhr",
|
|
r"(\d{2})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2})",
|
|
r"(\d{2})\.(\d{2})\.(\d{2})", # Just date, no time
|
|
]
|
|
|
|
# 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,
|
|
) -> List[Event]:
|
|
"""
|
|
Fetch all future/live events from the specified folder.
|
|
|
|
Args:
|
|
folder_id: The folder ID (default "6" for Sport im Überblick)
|
|
start_time: Filter events that end after this time
|
|
end_time: Filter events that start before this time
|
|
force_refresh: Ignore cache and fetch fresh data
|
|
|
|
Returns:
|
|
List of Event objects (scheduled or live)
|
|
"""
|
|
all_events: List[Event] = []
|
|
|
|
# Step 1: Fetch folder layout using provider's common method
|
|
layout = self._fetch_folder_layout(folder_id, force_refresh)
|
|
if not layout:
|
|
logger.error(f"Failed to fetch folder layout for {folder_id}")
|
|
return []
|
|
|
|
# Step 2: Identify live stream blocks
|
|
live_blocks = self._identify_live_stream_blocks(layout)
|
|
if not live_blocks:
|
|
logger.debug(f"No live stream blocks found in folder {folder_id}")
|
|
return []
|
|
|
|
logger.info(f"Found {len(live_blocks)} live stream blocks")
|
|
|
|
# Step 3: Fetch all pages from each block
|
|
for block in live_blocks:
|
|
block_events = self._fetch_all_block_pages(block, force_refresh)
|
|
all_events.extend(block_events)
|
|
|
|
# Step 4: Filter by time range
|
|
filtered_events = self._filter_by_time(all_events, start_time, end_time)
|
|
|
|
# Step 5: 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)} future events from {len(all_events)} total")
|
|
return filtered_events
|
|
|
|
def get_manifest_for_event(self, event_id: str) -> Optional[str]:
|
|
"""
|
|
Get manifest URL for an event.
|
|
|
|
Args:
|
|
event_id: The event folder ID (e.g., "76")
|
|
|
|
Returns:
|
|
Manifest URL if available from the folder layout, None otherwise
|
|
"""
|
|
layout = self._fetch_folder_layout(event_id)
|
|
if not layout:
|
|
return None
|
|
|
|
# Extract video assets from the layout
|
|
assets = self._provider.extract_video_assets(layout)
|
|
|
|
if not assets:
|
|
logger.debug(f"No video assets found in event folder {event_id}")
|
|
return None
|
|
|
|
# Extract best manifest URL
|
|
manifest_url = self._provider.extract_best_manifest_url(assets)
|
|
if manifest_url:
|
|
logger.debug(f"Found manifest for event {event_id}: {manifest_url}")
|
|
return manifest_url
|
|
|
|
return None
|
|
|
|
def get_drm_for_event(self, event_id: str) -> List[DRMConfig]:
|
|
"""
|
|
Get DRM configuration for an event.
|
|
"""
|
|
layout = self._fetch_folder_layout(event_id)
|
|
if not layout:
|
|
return []
|
|
|
|
# Delegate to provider's common DRM method
|
|
return self._provider.get_drm_for_content(layout)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Internal Methods
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _fetch_folder_layout(self, folder_id: str, force_refresh: bool = False) -> Optional[Dict]:
|
|
"""Fetch folder layout using provider's common method."""
|
|
return self._provider.fetch_layout(
|
|
layout_type="folder",
|
|
content_id=folder_id,
|
|
force_refresh=force_refresh,
|
|
)
|
|
|
|
def _fetch_block_page(self, block_id: str, page: int, nb_pages: int = 3) -> Optional[Dict]:
|
|
"""Fetch a specific page of a block using provider's common method."""
|
|
return self._provider.fetch_layout(
|
|
layout_type="block",
|
|
content_id=block_id,
|
|
block_page=page,
|
|
nb_pages=nb_pages,
|
|
)
|
|
|
|
def _identify_live_stream_blocks(self, layout: Dict) -> List[Dict]:
|
|
"""
|
|
Identify blocks that contain live stream events.
|
|
|
|
Returns:
|
|
List of block dictionaries that likely contain future events
|
|
"""
|
|
blocks = layout.get("blocks", [])
|
|
live_blocks = []
|
|
|
|
for block in blocks:
|
|
block_type = block.get("type")
|
|
if block_type != "bffPaginated":
|
|
continue
|
|
|
|
block_title = block.get("analytics", {}).get("tealium", {}).get("block_title", "")
|
|
|
|
# Check if block title matches live stream patterns
|
|
is_live_block = any(
|
|
title_pattern.lower() in block_title.lower()
|
|
for title_pattern in self.LIVE_STREAM_BLOCK_TITLES
|
|
)
|
|
|
|
if is_live_block:
|
|
logger.debug(f"Identified live block: '{block_title}'")
|
|
live_blocks.append(block)
|
|
continue
|
|
|
|
# Alternative: Check if ANY item in the block has a future date
|
|
items = block.get("content", {}).get("items", [])
|
|
for item in items:
|
|
highlight = item.get("itemContent", {}).get("highlight", "")
|
|
if highlight and self._is_future_date(highlight):
|
|
logger.debug(f"Block '{block_title}' contains future dates, including")
|
|
live_blocks.append(block)
|
|
break
|
|
|
|
return live_blocks
|
|
|
|
def _fetch_all_block_pages(self, block: Dict, force_refresh: bool = False) -> List[Event]:
|
|
"""
|
|
Fetch all pages of a paginated block and extract events.
|
|
"""
|
|
all_events: List[Event] = []
|
|
block_id = block.get("id")
|
|
if not block_id:
|
|
logger.warning("Block missing 'id' field")
|
|
return []
|
|
|
|
# Get pagination info from initial block data
|
|
pagination = block.get("content", {}).get("pagination", {})
|
|
total_items = pagination.get("totalItems", 0)
|
|
items_per_page = pagination.get("itemsPerPage", 4)
|
|
next_page = pagination.get("nextPage")
|
|
|
|
logger.debug(f"Block {block_id}: total={total_items}, per_page={items_per_page}, next_page={next_page}")
|
|
|
|
# Process the initial page
|
|
initial_items = block.get("content", {}).get("items", [])
|
|
current_page_events = self._extract_events_from_items(initial_items)
|
|
all_events.extend(current_page_events)
|
|
|
|
# Determine current page based on items count
|
|
current_page = 1
|
|
if total_items > 0 and items_per_page > 0:
|
|
# Rough estimate: if we have more items than items_per_page, we might be on page 2
|
|
if len(initial_items) > items_per_page:
|
|
current_page = 2
|
|
|
|
# Fetch remaining pages
|
|
while next_page and next_page > current_page:
|
|
current_page = next_page
|
|
page_data = self._fetch_block_page(block_id, next_page, force_refresh)
|
|
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", [])
|
|
page_events = self._extract_events_from_items(items)
|
|
all_events.extend(page_events)
|
|
|
|
# Get next_page from the response
|
|
pagination = page_data.get("content", {}).get("pagination", {})
|
|
next_page = pagination.get("nextPage")
|
|
|
|
# Safety: prevent infinite loops
|
|
if current_page > 50:
|
|
logger.warning(f"Reached page limit (50) for block {block_id}")
|
|
break
|
|
|
|
logger.debug(f"Block {block_id}: fetched {len(all_events)} events from {current_page} pages")
|
|
return all_events
|
|
|
|
def _extract_events_from_items(self, items: List[Dict]) -> List[Event]:
|
|
"""
|
|
Extract Event objects from block items.
|
|
"""
|
|
events: List[Event] = []
|
|
current_time = datetime.now()
|
|
|
|
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
|
|
event_date = self._parse_date_from_highlight(highlight)
|
|
if not event_date:
|
|
# Some items might have date in description
|
|
description = item_content.get("description", "")
|
|
if description:
|
|
event_date = self._parse_date_from_highlight(description)
|
|
if not event_date:
|
|
continue
|
|
|
|
# Only include future events (or currently live)
|
|
if event_date < current_time:
|
|
continue
|
|
|
|
# Extract event data - use itemContent.id which is the content_id for manifest
|
|
content_id = item_content.get("id")
|
|
title = item_content.get("title")
|
|
|
|
if not content_id:
|
|
continue
|
|
|
|
if not title:
|
|
# Try to extract title from highlight
|
|
title = self._extract_title_from_highlight(highlight)
|
|
|
|
if not title:
|
|
title = "Unknown Event"
|
|
|
|
# Determine status (live if within next 3 hours, else scheduled)
|
|
status = EventStatus.LIVE if self._is_currently_live(event_date) else EventStatus.SCHEDULED
|
|
|
|
# Extract sport type
|
|
sport = self._extract_sport_type(highlight)
|
|
|
|
# Get action target (for potential folder navigation)
|
|
action = item_content.get("action", {})
|
|
target = action.get("target", {})
|
|
|
|
# Build event
|
|
event = Event(
|
|
name=title,
|
|
content_id=content_id,
|
|
provider=self._provider.provider_name,
|
|
start_time=event_date,
|
|
end_time=None, # End time not provided in this response
|
|
status=status,
|
|
logo_url=self._extract_image_url(item_content),
|
|
genre=sport,
|
|
venue=item_content.get("details") or item_content.get("extraDetails"),
|
|
description=item_content.get("description"),
|
|
)
|
|
|
|
# Store additional metadata in manifest_script for later use
|
|
manifest_data = {
|
|
"folder_id": target.get("value_layout", {}).get("id"),
|
|
"seo": target.get("value_layout", {}).get("seo"),
|
|
"highlight": highlight,
|
|
"title": title,
|
|
}
|
|
event.manifest_script = json.dumps(manifest_data)
|
|
|
|
events.append(event)
|
|
|
|
return events
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Date Parsing Helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _parse_date_from_highlight(self, highlight: str) -> Optional[datetime]:
|
|
"""
|
|
Parse German date format from highlight string.
|
|
|
|
Examples:
|
|
"Fußball • Sa., 02.05.26, 20:00 Uhr"
|
|
"Motorsport • Fr., 15.05.26, 13:10 Uhr"
|
|
"Motorsport \u2022 Sa., 16.05.26, 14:15 Uhr"
|
|
"Motorsport • Do., 14.05.26, 13:10 Uhr"
|
|
"""
|
|
for pattern in self.DATE_PATTERNS:
|
|
match = re.search(pattern, highlight)
|
|
if match:
|
|
groups = match.groups()
|
|
if len(groups) >= 3:
|
|
day = int(groups[0])
|
|
month = int(groups[1])
|
|
year = int(groups[2])
|
|
hour = int(groups[3]) if len(groups) > 3 else 0
|
|
minute = int(groups[4]) if len(groups) > 4 else 0
|
|
|
|
# Assume 20XX for years like '26'
|
|
if year < 100:
|
|
year = 2000 + year
|
|
|
|
try:
|
|
return datetime(year, month, day, hour, minute)
|
|
except ValueError:
|
|
continue
|
|
|
|
return None
|
|
|
|
def _is_future_date(self, highlight: str) -> bool:
|
|
"""Check if the highlight contains a future date."""
|
|
event_date = self._parse_date_from_highlight(highlight)
|
|
if not event_date:
|
|
return False
|
|
return event_date > datetime.now()
|
|
|
|
@staticmethod
|
|
def _is_currently_live(event_date: datetime) -> bool:
|
|
"""Check if an event is currently live (within 3 hours of start)."""
|
|
now = datetime.now()
|
|
# Consider event "live" if it started within the last 3 hours
|
|
# and hasn't been passed by more than 30 minutes
|
|
return event_date <= now <= (event_date.replace(hour=event_date.hour + 3))
|
|
|
|
@staticmethod
|
|
def _extract_title_from_highlight(highlight: str) -> str:
|
|
"""Extract event title from highlight text when no separate title exists."""
|
|
# Remove sport prefix and date
|
|
parts = highlight.split("•")
|
|
if len(parts) >= 2:
|
|
# Everything between the first bullet and the date
|
|
middle = parts[1].strip()
|
|
# Remove date part
|
|
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"
|
|
|
|
@staticmethod
|
|
def _extract_image_url(item_content: Dict) -> Optional[str]:
|
|
"""Extract image URL from item content."""
|
|
image = item_content.get("image", {})
|
|
if not image:
|
|
return None
|
|
|
|
# Try to get the best available ratio
|
|
ratio_prefs = ["16:9", "3:1", "1:1"]
|
|
for ratio in ratio_prefs:
|
|
image_id = image.get("idsByRatio", {}).get(ratio)
|
|
if image_id:
|
|
return f"https://images.rtl.de/{image_id}?format=webp&width=400"
|
|
|
|
# Fallback to direct ID
|
|
image_id = image.get("id")
|
|
if image_id:
|
|
return f"https://images.rtl.de/{image_id}?format=webp&width=400"
|
|
|
|
return None
|
|
|
|
@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.end_time and event.end_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" # Match _fetch_layout cache key format
|
|
self._provider.invalidate_layout_cache(cache_key)
|
|
else:
|
|
self._provider.invalidate_layout_cache() |