Implement RTL Events

This commit is contained in:
Nirvana
2026-05-08 09:54:08 +02:00
parent 95f657393d
commit ee52b7040b
4 changed files with 574 additions and 399 deletions
@@ -3,17 +3,18 @@
RTL+ Event Manager
Handles fetching future/live events from Bedrock layout API.
Replaces legacy GraphQL-based event fetching.
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.
"""
import json
import re
from datetime import datetime
from typing import List, Optional, Dict
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Set
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:
@@ -21,23 +22,19 @@ 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",
]
# 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",
@@ -74,47 +71,50 @@ class RTLPlusEventManager:
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 from the specified folder.
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.
Args:
folder_id: The folder ID (default "6" for Sport im Überblick)
folder_id: Fallback folder ID (default "6" for Sport)
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
max_pages: Maximum number of pagination pages to fetch
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 []
# PRIMARY: Fetch from homepage (has all events in one block)
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")
else:
# FALLBACK: Fetch from specific folder
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)
# 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
# Filter by time range
filtered_events = self._filter_by_time(all_events, start_time, end_time)
# Step 5: Sort chronologically
# 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")
logger.info(f"Returning {len(filtered_events)} events after filtering")
return filtered_events
def get_manifest_for_event(self, event_id: str) -> Optional[str]:
@@ -122,158 +122,192 @@ class RTLPlusEventManager:
Get manifest URL for an event.
Args:
event_id: The event folder ID (e.g., "76")
event_id: The event folder ID (e.g., "95" for Freiburg vs Braga)
Returns:
Manifest URL if available from the folder layout, None otherwise
Manifest URL if available, None otherwise
"""
layout = self._fetch_folder_layout(event_id)
layout = self._provider.fetch_layout(layout_type="folder", content_id=event_id)
if not layout:
logger.debug(f"No folder layout found for event {event_id}")
return None
# Extract video assets from the layout
# First try: Extract manifest from the folder's solo block
manifest = self._extract_manifest_from_folder_layout(layout)
if manifest:
logger.debug(f"Found manifest in folder layout for event {event_id}")
return manifest
# Second try: Extract video assets directly from layout
assets = self._provider.extract_video_assets(layout)
if assets:
manifest_url = self._provider.extract_best_manifest_url(assets)
if manifest_url:
logger.debug(f"Found manifest from assets for event {event_id}")
return manifest_url
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
logger.debug(f"No manifest found for event {event_id}")
return None
def get_drm_for_event(self, event_id: str) -> List[DRMConfig]:
"""
Get DRM configuration for an event.
Args:
event_id: The event folder ID
Returns:
List of DRMConfig objects
"""
layout = self._fetch_folder_layout(event_id)
layout = self._provider.fetch_layout(layout_type="folder", content_id=event_id)
if not layout:
return []
# Delegate to provider's common DRM method
return self._provider.get_drm_for_content(layout)
# --------------------------------------------------------------------------
# Internal Methods
# Homepage Event Fetching (Primary Source)
# --------------------------------------------------------------------------
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,
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,
)
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")
if not home_layout:
logger.warning("Failed to fetch home layout for events")
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")
live_block = self._find_live_events_block(home_layout)
if not live_block:
logger.debug("No live events block found on homepage")
return []
logger.debug(f"Block {block_id}: total={total_items}, per_page={items_per_page}, next_page={next_page}")
block_id = live_block.get("id")
if not block_id:
logger.warning("Live events block missing 'id' field")
return []
# 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)
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()
# 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
# 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",
)
# 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)
all_events.extend(self._extract_events_from_items(items))
pages_fetched += 1
# 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")
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
block_title = (
block.get("analytics", {})
.get("tealium", {})
.get("block_title", "")
)
# Exact match — return immediately
if block_title == "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] = []
current_time = datetime.now()
@@ -288,131 +322,144 @@ class RTLPlusEventManager:
if not highlight:
continue
# Parse the date from highlight
event_date = self._parse_date_from_highlight(highlight)
# Parse the date from highlight, falling back to description
event_date = parse_german_datetime(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
event_date = parse_german_datetime(description)
if not event_date:
continue
# Only include future events (or currently live)
# Only include future events or currently live ones
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)
# Extract title
title = item_content.get("title") or 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
# 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")
# Extract sport type
# 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
status = self._determine_event_status(event_date, highlight)
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,
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=self._extract_image_url(item_content),
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 in manifest_script for later use
manifest_data = {
"folder_id": target.get("value_layout", {}).get("id"),
"seo": target.get("value_layout", {}).get("seo"),
# 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,
}
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]:
def _extract_manifest_from_folder_layout(self, layout: Dict) -> Optional[str]:
"""
Parse German date format from highlight string.
Extract manifest URL from a folder layout (live event detail page).
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"
The folder layout has a Solo block with a "Live ansehen" button.
The target points to a live player, which we need to fetch.
"""
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
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
continue
# Assume 20XX for years like '26'
if year < 100:
year = 2000 + year
for item in block.get("content", {}).get("items", []):
item_content = item.get("itemContent", {})
action = item_content.get("action", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
try:
return datetime(year, month, day, hour, minute)
except ValueError:
continue
# The target type "live" points to the player
if value_layout.get("type") == "live":
live_event_id = value_layout.get("id")
if live_event_id:
return self._get_manifest_from_live_event(live_event_id)
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()
def _get_manifest_from_live_event(self, live_event_id: str) -> Optional[str]:
"""
Get manifest from live event player page.
Args:
live_event_id: The live event ID (e.g., "rtlde_event3")
"""
live_layout = self._provider.fetch_layout(
layout_type="live",
content_id=live_event_id,
location=f"{self._provider.rtl_config.base_website}{live_event_id}",
)
if live_layout:
assets = self._provider.extract_video_assets(live_layout)
if assets:
return self._provider.extract_best_manifest_url(assets)
return None
# --------------------------------------------------------------------------
# Date / Status / Sport Helpers
# --------------------------------------------------------------------------
@staticmethod
def _is_currently_live(event_date: datetime) -> bool:
"""Check if an event is currently live (within 3 hours of start)."""
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()
# 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))
# 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 and event_date >= now - timedelta(hours=3):
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."""
# Remove sport prefix and date
import re
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()
@@ -428,26 +475,9 @@ class RTLPlusEventManager:
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
# --------------------------------------------------------------------------
# Time Filtering
# --------------------------------------------------------------------------
@staticmethod
def _filter_by_time(
@@ -461,7 +491,7 @@ class RTLPlusEventManager:
filtered = []
for event in events:
if start_time and event.end_time and event.end_time < start_time:
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
@@ -476,7 +506,7 @@ class RTLPlusEventManager:
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
cache_key = f"folder:{folder_id}:1:2"
self._provider.invalidate_layout_cache(cache_key)
else:
self._provider.invalidate_layout_cache()
@@ -0,0 +1,199 @@
# streaming_providers/providers/rtlplus/layout_helpers.py
"""
RTL+ Layout Helpers
Pure, stateless utilities shared across event_manager, vod_manager, and
channel_manager. No I/O, no provider references — safe to import anywhere.
Centralises:
- Lock-target unwrapping (_unwrap_target)
- German date parsing (parse_german_datetime)
- Signed image URL construction (build_image_url, extract_thumbnail)
"""
import hashlib
import re
from datetime import datetime
from typing import Dict, Optional
from .constants import RTLPlusDefaults
# ---------------------------------------------------------------------------
# Lock-target unwrapping
# ---------------------------------------------------------------------------
def unwrap_target(target: Dict) -> Dict:
"""
Unwrap a lock-wrapped action target to its original target dict.
The Bedrock API wraps pay-walled or auth-gated targets like this::
{
"type": "lock",
"value_lock": {
"originalTarget": { "type": "...", "value_layout": {...} }
}
}
Calling this function on any target — locked or not — always returns the
"real" target so callers never need the ``if target.get("type") == "lock"``
boilerplate.
Args:
target: The raw ``action.target`` dict from a layout item.
Returns:
The unwrapped target dict, or the original dict if it wasn't locked.
"""
if not isinstance(target, dict):
return {}
if target.get("type") == "lock":
lock_value = target.get("value_lock", {})
if isinstance(lock_value, dict):
inner = lock_value.get("originalTarget", {})
if isinstance(inner, dict):
return inner
return target
# ---------------------------------------------------------------------------
# German date parsing
# ---------------------------------------------------------------------------
# Ordered from most-specific to least-specific so the first match wins.
_DATE_PATTERNS = [
# "Do., 07.05.26, 20:30 Uhr" (weekday, 2-digit year, with "Uhr")
r"[A-Za-z]+\.?,\s*(\d{2})\.(\d{2})\.(\d{2}),\s*(\d{2}):(\d{2})\s*Uhr",
# "Do., 07.05.26, 20:30" (weekday, 2-digit year, without "Uhr")
r"[A-Za-z]+\.?,\s*(\d{2})\.(\d{2})\.(\d{2}),\s*(\d{2}):(\d{2})",
# "07.05.26, 20:30 Uhr" (no weekday, 2-digit year, with "Uhr")
r"(\d{2})\.(\d{2})\.(\d{2}),\s*(\d{2}):(\d{2})\s*Uhr",
# "07.05.2026, 20:30" (no weekday, 4-digit year)
r"(\d{2})\.(\d{2})\.(\d{4}),\s*(\d{2}):(\d{2})",
# "07.05.26 20:30" (no comma)
r"(\d{2})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2})",
# "07.05.26" (date only, no time)
r"(\d{2})\.(\d{2})\.(\d{2,4})",
]
def parse_german_datetime(text: str) -> Optional[datetime]:
"""
Parse a German-formatted date/time string embedded in arbitrary text.
Handles all variants seen in RTL+ Bedrock highlight strings, e.g.::
"Fußball • Do., 07.05.26, 20:30 Uhr"
"Motorsport • Fr., 15.05.26, 13:10 Uhr"
"Motorsport \u2022 Sa., 16.05.26, 14:15 Uhr"
"07.05.2026, 20:30"
Two-digit years are expanded to the 21st century (``26`` → ``2026``).
Args:
text: Any string that may contain a German date.
Returns:
A ``datetime`` object, or ``None`` if no recognisable date was found.
"""
for pattern in _DATE_PATTERNS:
match = re.search(pattern, text)
if not match:
continue
groups = match.groups()
if len(groups) < 3:
continue
try:
day = int(groups[0])
month = int(groups[1])
year = int(groups[2])
# Expand 2-digit year
if year < 100:
year = 2000 + year
hour = int(groups[3]) if len(groups) > 3 else 0
minute = int(groups[4]) if len(groups) > 4 else 0
return datetime(year, month, day, hour, minute)
except ValueError:
continue # Try the next pattern
return None
# ---------------------------------------------------------------------------
# Signed image URL construction
# ---------------------------------------------------------------------------
def build_image_url(image_id: str) -> str:
"""
Build a signed Bedrock CDN image URL for the given image ID.
Hash formula: ``SHA1("/v2/images/{id}/raw?{params}" + IMAGE_SIGNING_KEY)``
The signing key is appended as a suffix (secret-suffix construction),
verified against multiple known-good URLs captured from network traces.
Args:
image_id: The raw image ID returned by the Bedrock API.
Returns:
A fully-qualified, signed image URL.
"""
suffix = f"/{image_id}/raw?{RTLPlusDefaults.IMAGE_PARAMS}"
signed_path = f"/v2/images{suffix}"
image_hash = hashlib.sha1(
(signed_path + RTLPlusDefaults.IMAGE_SIGNING_KEY).encode()
).hexdigest()
return f"{RTLPlusDefaults.IMAGE_BASE_URL}{suffix}&hash={image_hash}"
def extract_thumbnail(item_content: Dict) -> Optional[str]:
"""
Extract the best available thumbnail URL from a Bedrock item's content dict.
Tries aspect ratios in preference order: 16:9, 3:1, 1:1, 2:3.
Falls back to the top-level ``image.id`` if no ratio-keyed ID is found.
Args:
item_content: The ``itemContent`` dict from a Bedrock block item.
Returns:
A signed CDN URL, or ``None`` if no image data is present.
"""
image = item_content.get("image", {})
if not image:
return None
for ratio in ("16:9", "3:1", "1:1", "2:3"):
image_id = image.get("idsByRatio", {}).get(ratio)
if image_id:
return build_image_url(image_id)
image_id = image.get("id")
if image_id:
return build_image_url(image_id)
return None
def extract_thumbnail_from_layout(layout: Dict) -> Optional[str]:
"""
Extract a thumbnail URL from a top-level program/video layout dict.
Reads from ``layout.seo.image.id``, which is populated on program and
video layout responses but not on individual block items.
Args:
layout: A full Bedrock layout response dict.
Returns:
A signed CDN URL, or ``None`` if no SEO image is present.
"""
image_id = layout.get("seo", {}).get("image", {}).get("id")
if image_id:
return build_image_url(image_id)
return None
@@ -10,6 +10,7 @@ from ...base.provider import StreamingProvider
from ...base.utils import logger
from .auth import RTLPlusAuthenticator
from .constants import RTLPlusConfig, RTLPlusDefaults
from .layout_helpers import unwrap_target
from .vod_manager import RTLPlusVodManager
from .channel_manager import RTLPlusChannelManager
from .event_manager import RTLPlusEventManager
@@ -117,7 +118,7 @@ class RTLPlusProvider(StreamingProvider):
force_refresh: bool = False,
) -> Optional[Dict]:
"""
Fetch any layout (live/video/folder/program/block) with caching.
Fetch any layout (live/video/folder/program/block/alias) with caching.
"""
if block_page is None:
block_page = RTLPlusDefaults.DEFAULT_BLOCK_PAGE
@@ -191,6 +192,63 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to fetch {layout_type} layout for {clean_content_id}: {e}")
return None
def fetch_block_page(
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.
URL pattern: /service/{service_id}/block/{block_id}?nbPages={nb_pages}&page={page}
This is intentionally separate from ``fetch_layout`` because the service/block
endpoint uses different URL structure and query parameters (``page`` / ``nbPages``
instead of ``blockPage`` / ``nbPages``), and its responses are not cached — the
same page number may return different content as events are added or removed.
Args:
block_id: Full block ID as returned by the layout API, e.g.
``"page_69fcecf6347a02.20778370--6294a9fc-55a7-43cf-aa18-e27f1bd1a2c3"``
page: Page number to fetch. May be non-sequential (1 → 3 → 6 → …)
as dictated by the API's ``pagination.nextPage`` field.
nb_pages: Number of pages to request per call (default 3, matching API default).
service_id: Bedrock service identifier (default ``"rtlplus_root"``).
Returns:
Parsed JSON response dict, or ``None`` on failure.
"""
url = f"{self.rtl_config.bedrock_layout_base}/service/{service_id}/block/{block_id}"
params = {"nbPages": nb_pages, "page": page}
oauth_token = self.get_user_bearer_token() or self.authenticator.get_bearer_token()
if not oauth_token:
logger.error("No OAuth token available for fetch_block_page")
return None
try:
bedrock_token = self.authenticator.get_bedrock_token()
except Exception as e:
logger.error(f"Failed to get Bedrock token for fetch_block_page: {e}")
return None
if not bedrock_token:
logger.error("No Bedrock token available for fetch_block_page")
return None
location = f"{self.rtl_config.base_website}"
headers = self.rtl_config.get_layout_headers(oauth_token, bedrock_token, location)
try:
response = self.http_manager.get(url, headers=headers, params=params, operation="api")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch block page {page} for {block_id}: {e}")
return None
@staticmethod
def extract_video_assets(layout_data: Dict) -> List[Dict]:
"""
@@ -401,7 +459,7 @@ class RTLPlusProvider(StreamingProvider):
# If content_id starts with program_, we need to find the clip_id from the program
if content_id.startswith("program_"):
program_id = content_id[8:] # Remove "program_" prefix
layout = self._fetch_program_layout(program_id)
layout = self.fetch_layout(layout_type="program", content_id=program_id)
if layout:
clip_id = self._find_clip_id_in_program_layout(layout)
if clip_id:
@@ -426,37 +484,6 @@ class RTLPlusProvider(StreamingProvider):
# Fall back to VOD/event manifest extraction
return self._get_manifest_vod_or_event(content_id, **kwargs)
def _fetch_program_layout(self, program_id: str) -> Optional[Dict]:
"""Fetch a program layout by program ID."""
oauth_token = self.get_user_bearer_token()
if not oauth_token:
logger.error("No user authentication for program layout")
return None
try:
bedrock_token = self.authenticator.get_bedrock_token()
if not bedrock_token:
logger.error("Failed to get Bedrock token")
return None
except Exception as e:
logger.error(f"Failed to get Bedrock token: {e}")
return None
clean_id = program_id.replace("program_", "")
url = f"{self.rtl_config.bedrock_layout_base}/program/{clean_id}/layout"
location = f"{self.rtl_config.base_website}p_{clean_id}-p_{clean_id}"
headers = self.rtl_config.get_layout_headers(oauth_token, bedrock_token, location)
params = {"blockPage": 1, "nbPages": 2}
try:
response = self.http_manager.get(url, headers=headers, params=params, operation="api")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch program layout for {program_id}: {e}")
return None
def _find_clip_id_in_program_layout(self, layout: Dict) -> Optional[str]:
"""Extract the clip_id from a program layout."""
if not layout:
@@ -485,11 +512,7 @@ class RTLPlusProvider(StreamingProvider):
item_content = item.get("itemContent", {})
action = item_content.get("action", {})
target = action.get("target", {})
if target.get("type") == "lock":
target = target.get("value_lock", {}).get("originalTarget", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
if value_layout.get("type") == "video":
return value_layout.get("id")
@@ -509,7 +532,7 @@ class RTLPlusProvider(StreamingProvider):
# If content_id starts with program_, find the clip_id
if content_id.startswith("program_"):
program_id = content_id[8:]
layout = self._fetch_program_layout(program_id)
layout = self.fetch_layout(layout_type="program", content_id=program_id)
if layout:
clip_id = self._find_clip_id_in_program_layout(layout)
if clip_id:
@@ -692,26 +715,6 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to get DRM: {e}")
return []
def _fetch_manifest_data(self, content_id: str) -> Optional[list]:
"""Fetch raw manifest data for VOD/events, with cache."""
now = time.monotonic()
cached = self._manifest_cache.get(content_id)
if cached is not None:
data, ts = cached
if (now - ts) < _MANIFEST_CACHE_TTL:
return data
# TODO: This legacy endpoint may eventually be migrated to Bedrock
manifest_url = f"https://stus.player.streamingtech.de/watch-playout-variants/{content_id}?platform=web"
headers = {"X-Auth-Token": self.authenticator.get_bearer_token()}
response = self.http_manager.get(manifest_url, operation="manifest", headers=headers)
response.raise_for_status()
data = response.json()
self._manifest_cache[content_id] = (data, now)
return data
def get_user_bearer_token(self) -> Optional[str]:
"""Get a user-authenticated bearer token, upgrading if necessary. Returns None if impossible."""
from ...base.auth.base_auth import TokenAuthLevel
@@ -20,6 +20,13 @@ from ...base.models.vod import VodCategory, VodItem
from ...base.models import DRMConfig
from ...base.utils.logger import logger
from .constants import RTLPlusDefaults
from .layout_helpers import (
unwrap_target,
parse_german_datetime,
build_image_url,
extract_thumbnail,
extract_thumbnail_from_layout,
)
FOLDER_NAMES = {
"4": "Filme",
@@ -323,12 +330,7 @@ class RTLPlusVodManager:
item_content = item.get("itemContent", {})
action = item_content.get("action", {})
target = action.get("target", {})
# Handle lock-wrapped targets
if target.get("type") == "lock":
target = target.get("value_lock", {}).get("originalTarget", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
if value_layout.get("type") == "video" and value_layout.get("id") == clip_id:
@@ -534,7 +536,7 @@ class RTLPlusVodManager:
if layout_title and layout_title != movie_item.name:
movie_item.name = layout_title
if not movie_item.logo_url:
movie_item.logo_url = self._extract_thumbnail_from_layout(layout)
movie_item.logo_url = extract_thumbnail_from_layout(layout)
if not movie_item.description:
movie_item.description = (
layout.get("entity", {}).get("metadata", {}).get("description")
@@ -791,22 +793,15 @@ class RTLPlusVodManager:
# Check for video in action target
action = item_content.get("action", {})
if isinstance(action, dict):
target = action.get("target", {})
if isinstance(target, dict):
# Handle lock-wrapped targets
if target.get("type") == "lock":
lock_value = target.get("value_lock", {})
if isinstance(lock_value, dict):
target = lock_value.get("originalTarget", {})
value_layout = target.get("value_layout", {})
if isinstance(value_layout, dict) and value_layout.get("type") == "video":
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
clip_id = value_layout.get("id")
if clip_id:
vod_item.content_id = clip_id
return vod_item
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
if isinstance(value_layout, dict) and value_layout.get("type") == "video":
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
clip_id = value_layout.get("id")
if clip_id:
vod_item.content_id = clip_id
return vod_item
# Check direct itemContent type
if item_content.get("type") == "video":
@@ -820,7 +815,7 @@ class RTLPlusVodManager:
episode_number=-1,
)
vod_item.description = item_content.get("description")
vod_item.logo_url = self._extract_thumbnail(item_content)
vod_item.logo_url = extract_thumbnail(item_content)
return vod_item
return None
@@ -944,7 +939,7 @@ class RTLPlusVodManager:
episode_number=video_meta.get("episode", -1),
)
vod_item.description = metadata.get("description")
vod_item.logo_url = self._extract_thumbnail_from_layout(layout)
vod_item.logo_url = extract_thumbnail_from_layout(layout)
vod_item.duration_seconds = video_meta.get("duration")
vod_item.genre = parent.get("seo", "")
vod_item.series_title = parent.get("name")
@@ -977,16 +972,11 @@ class RTLPlusVodManager:
logger.warning(f"_extract_vod_item_from_block_item: item_content is {type(item_content)}")
return None
# Try to get video reference from action target first
# Unwrap lock-wrapped targets before reading the action target
action = item_content.get("action", {})
target = action.get("target", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
# Also check for lock-wrapped targets
if target.get("type") == "lock":
target = target.get("value_lock", {}).get("originalTarget", {})
value_layout = target.get("value_layout", {})
clip_id = None
program_id = None
program_slug = None
@@ -1004,16 +994,15 @@ class RTLPlusVodManager:
for action_key in ("onClickAction", "primaryAction", "secondaryAction"):
alt_action = item_content.get(action_key, {})
if isinstance(alt_action, dict):
alt_target = alt_action.get("target", {})
if isinstance(alt_target, dict):
alt_value = alt_target.get("value_layout", {})
if isinstance(alt_value, dict) and alt_value.get("type") == "video":
clip_id = alt_value.get("id")
parent = alt_value.get("parent", {})
program_id = parent.get("id")
program_slug = parent.get("seo")
if clip_id:
break
alt_target = unwrap_target(alt_action.get("target", {}))
alt_value = alt_target.get("value_layout", {})
if isinstance(alt_value, dict) and alt_value.get("type") == "video":
clip_id = alt_value.get("id")
parent = alt_value.get("parent", {})
program_id = parent.get("id")
program_slug = parent.get("seo")
if clip_id:
break
if not clip_id:
return None
@@ -1064,7 +1053,7 @@ class RTLPlusVodManager:
episode_number=episode_number if episode_number is not None else -1,
)
vod_item.description = item_content.get("description")
vod_item.logo_url = self._extract_thumbnail(item_content)
vod_item.logo_url = extract_thumbnail(item_content)
vod_item.duration_seconds = self._extract_duration(item_content)
vod_item.progress = item_content.get("progress", 0)
@@ -1088,12 +1077,7 @@ class RTLPlusVodManager:
return None
action = item_content.get("action", {})
target = action.get("target", {})
# Handle lock-wrapped targets
if target.get("type") == "lock":
target = target.get("value_lock", {}).get("originalTarget", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
layout_type = value_layout.get("type")
@@ -1132,7 +1116,7 @@ class RTLPlusVodManager:
name=name,
content_id=content_id,
provider=self._provider.provider_name,
logo_url=self._extract_thumbnail(item_content),
logo_url=extract_thumbnail(item_content),
description=item_content.get("description") or item_content.get("highlight"),
)
@@ -1149,45 +1133,21 @@ class RTLPlusVodManager:
# Thumbnail / duration helpers (pure, no I/O)
# ------------------------------------------------------------------
@staticmethod
def _build_image_url(image_id: str) -> str:
"""Build a signed Bedrock CDN image URL.
# build_image_url and extract_thumbnail are re-exported here as classmethods
# for any callers that still reference them via the class (e.g. tests).
# New code should import directly from layout_helpers.
Hash formula: SHA1("/v2/images/{id}/raw?{params}" + IMAGE_SIGNING_KEY)
Note: path_and_query starts with /{id}/raw since IMAGE_BASE_URL already
contains /v2/images — the full signed path is /v2/images/{id}/raw?{params}.
This is a keyed hash (secret-suffix construction), verified against
three known-good URLs from network traces.
"""
import hashlib
from .constants import RTLPlusDefaults
suffix = f"/{image_id}/raw?{RTLPlusDefaults.IMAGE_PARAMS}"
signed_path = f"/v2/images{suffix}"
image_hash = hashlib.sha1(
(signed_path + RTLPlusDefaults.IMAGE_SIGNING_KEY).encode()
).hexdigest()
return f"{RTLPlusDefaults.IMAGE_BASE_URL}{suffix}&hash={image_hash}"
@classmethod
def _build_image_url(cls, image_id: str) -> str:
return build_image_url(image_id)
@classmethod
def _extract_thumbnail(cls, item_content: Dict) -> Optional[str]:
image = item_content.get("image", {})
if not image:
return None
for ratio in ("16:9", "3:1", "1:1", "2:3"):
image_id = image.get("idsByRatio", {}).get(ratio)
if image_id:
return cls._build_image_url(image_id)
image_id = image.get("id")
if image_id:
return cls._build_image_url(image_id)
return None
return extract_thumbnail(item_content)
@classmethod
def _extract_thumbnail_from_layout(cls, layout: Dict) -> Optional[str]:
image_id = layout.get("seo", {}).get("image", {}).get("id")
if image_id:
return cls._build_image_url(image_id)
return None
return extract_thumbnail_from_layout(layout)
@staticmethod
def _extract_duration(item_content: Dict) -> Optional[int]:
@@ -1208,25 +1168,8 @@ class RTLPlusVodManager:
highlight = item_content.get("highlight", "")
if not highlight:
return False
date_patterns = [
r"(\d{2})\.(\d{2})\.(\d{2}),?\s*(\d{2}):(\d{2})", # DD.MM.YY, HH:MM
r"(\d{2})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2})", # DD.MM.YY HH:MM
]
for pattern in date_patterns:
match = re.search(pattern, highlight)
if match:
day, month, year, hour, minute = map(int, match.groups())
if year < 100:
year = 2000 + year
try:
event_date = datetime(year, month, day, hour, minute)
if event_date > datetime.now():
return True
except ValueError:
continue
return False
event_date = parse_german_datetime(highlight)
return event_date is not None and event_date > datetime.now()
@staticmethod
def _is_event_item(item_content: Dict) -> bool: