Prepare RTLPlus for new streaming prov

This commit is contained in:
Nirvana
2026-05-03 18:21:00 +02:00
parent 490852a00b
commit 4dc0cb352a
2 changed files with 60 additions and 329 deletions
@@ -1,5 +1,4 @@
# streaming_providers/providers/rtlplus/provider.py
import json
import time
from datetime import datetime
from typing import ClassVar, Dict, List, Optional, Tuple
@@ -63,7 +62,6 @@ class RTLPlusProvider(StreamingProvider):
# Try authentication
try:
self.bearer_token = self.authenticator.get_bearer_token()
logger.debug("RTL+ authentication successful during initialization")
# Ensure a profile is selected for user-authenticated sessions
if self.authenticator.has_user_credentials():
@@ -126,24 +124,14 @@ class RTLPlusProvider(StreamingProvider):
if nb_pages is None:
nb_pages = RTLPlusDefaults.DEFAULT_NB_PAGES
# 🔧 FIX: Clean up content_id based on layout_type
original_content_id = content_id
# Strip known type prefixes so the correct URL and cache key are used
clean_content_id = content_id
if layout_type == "program" and content_id.startswith("program_"):
# Remove "program_" prefix to get just the numeric ID
clean_content_id = content_id[8:] # "program_68137" -> "68137"
logger.debug(f"Cleaned program content_id: '{original_content_id}' -> '{clean_content_id}'")
clean_content_id = content_id[8:]
elif layout_type == "folder" and content_id.startswith("folder_"):
# Remove "folder_" prefix
clean_content_id = content_id[7:]
logger.debug(f"Cleaned folder content_id: '{original_content_id}' -> '{clean_content_id}'")
elif layout_type == "season" and content_id.startswith("season_"):
# Remove "season_" prefix
clean_content_id = content_id[7:]
logger.debug(f"Cleaned season content_id: '{original_content_id}' -> '{clean_content_id}'")
# For video layouts, a prefix is always an error
if layout_type == "video" and "_" in content_id and not content_id.startswith("clip"):
@@ -159,7 +147,6 @@ class RTLPlusProvider(StreamingProvider):
if not force_refresh and cache_key in self._layout_cache:
cached_data, cached_time = self._layout_cache[cache_key]
if (now - cached_time) < self.LAYOUT_CACHE_TTL:
logger.debug(f"Layout cache hit: {cache_key}")
return cached_data
# Get tokens
@@ -167,7 +154,6 @@ class RTLPlusProvider(StreamingProvider):
if not oauth_token:
try:
oauth_token = self.authenticator.get_bearer_token()
logger.debug("Using anonymous token for layout")
except Exception as e:
logger.error(f"Failed to get OAuth token for layout: {e}")
return None
@@ -184,31 +170,13 @@ class RTLPlusProvider(StreamingProvider):
# Build request - handle alias layouts specially
if layout_type == "alias":
# For alias layouts, the URL pattern is: {base}/alias/{content_id}/layout
url = f"{self.rtl_config.bedrock_layout_base}/alias/{clean_content_id}/layout"
else:
# Use the cleaned content_id for the URL
url = self.rtl_config.get_layout_url(layout_type, clean_content_id)
headers = self.rtl_config.get_layout_headers(oauth_token, bedrock_token, location)
params = {"blockPage": block_page, "nbPages": nb_pages}
# LOG ALL HEADERS BEING SENT
logger.debug(f"=== FETCH LAYOUT REQUEST HEADERS ===")
logger.debug(f"URL: {url}")
logger.debug(f"Method: GET")
logger.debug(f"Headers:")
# Mask sensitive tokens for security
safe_headers = {}
for key, value in headers.items():
if 'authorization' in key.lower() or 'token' in key.lower():
safe_headers[key] = f"{value[:20]}... (truncated)" if len(value) > 20 else "***"
else:
safe_headers[key] = value
logger.debug(f"{json.dumps(safe_headers, indent=2)}")
logger.debug(f"Params: {params}")
logger.debug(f"==================================")
try:
response = self.http_manager.get(
url, headers=headers, params=params, operation="api"
@@ -217,8 +185,6 @@ class RTLPlusProvider(StreamingProvider):
data = response.json()
self._layout_cache[cache_key] = (data, now)
logger.debug(f"Fetched {layout_type} layout for {clean_content_id}")
return data
except Exception as e:
@@ -233,39 +199,20 @@ class RTLPlusProvider(StreamingProvider):
assets = []
blocks = layout_data.get("blocks", [])
logger.debug(f"extract_video_assets: Found {len(blocks)} blocks")
for block_idx, block in enumerate(blocks):
block_type = block.get("type")
logger.debug(f"Block {block_idx}: type={block_type}")
for block in blocks:
if block.get("type") != "bffPaginated":
logger.debug(f" Skipping block {block_idx} - not bffPaginated")
continue
items = block.get("content", {}).get("items", [])
logger.debug(f" Block {block_idx} has {len(items)} items")
for item_idx, item in enumerate(items):
item_type = item.get("itemType")
logger.debug(f" Item {item_idx}: itemType={item_type}")
for item in items:
if item.get("itemType") == "video":
video = item.get("itemContent", {}).get("video", {})
video_assets = video.get("assets", [])
logger.debug(f" Found {len(video_assets)} assets in video item")
video_assets = item.get("itemContent", {}).get("video", {}).get("assets", [])
assets.extend(video_assets)
elif item.get("itemType") == "classic":
# Some layouts wrap video inside classic items
item_content = item.get("itemContent", {})
video = item_content.get("video", {})
video_assets = video.get("assets", [])
logger.debug(f" Found {len(video_assets)} assets in classic item")
video_assets = item.get("itemContent", {}).get("video", {}).get("assets", [])
assets.extend(video_assets)
else:
logger.debug(f" Skipping item - unknown itemType: {item_type}")
logger.debug(f"extract_video_assets: Total assets found: {len(assets)}")
return assets
@staticmethod
@@ -325,22 +272,16 @@ class RTLPlusProvider(StreamingProvider):
quality = preferred_quality or next(iter(self.rtl_config.preferred_qualities), "hd")
format_pref = preferred_format or next(iter(self.rtl_config.preferred_formats), "dashcenc")
# Try exact match
for asset in assets:
if (asset.get("quality") == quality and
asset.get("format") == format_pref):
manifest_url = asset.get("path") or asset.get("reference")
if manifest_url:
return manifest_url
# Try preferred format + quality first, then broaden search
for fmt in (format_pref,):
for qual in (quality,):
for asset in assets:
if asset.get("quality") == qual and asset.get("format") == fmt:
manifest_url = asset.get("path") or asset.get("reference")
if manifest_url:
return manifest_url
# Try format + quality, any DRM
for asset in assets:
if asset.get("quality") == quality and asset.get("format") == format_pref:
manifest_url = asset.get("path") or asset.get("reference")
if manifest_url:
return manifest_url
# Try by preferred formats/qualities
# Try by all preferred formats/qualities
for fmt in self.rtl_config.preferred_formats:
for qual in self.rtl_config.preferred_qualities:
for asset in assets:
@@ -446,36 +387,25 @@ class RTLPlusProvider(StreamingProvider):
- "program_68137" (we need to extract the clip_id from the program)
- "program_68137/clip_1417600"
"""
logger.debug(f"get_manifest called with content_id={content_id}, kwargs={list(kwargs.keys())}")
# Check if we have a stored context from the VOD item
# Kodi passes the program_id as content_id, but we stored the clip context
if "program_context" in kwargs:
# This should contain the clip_id we need
if kwargs["program_context"].get("clip_id"):
content_id = kwargs["program_context"]["clip_id"]
logger.debug(f"Using clip_id from program_context: {content_id}")
# Handle combined paths
if "/" in content_id:
parts = content_id.split("/")
if len(parts) == 2 and parts[0].startswith("program_"):
content_id = parts[1] # clip_1417600
logger.debug(f"Extracted clip_id from path: {content_id}")
# 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
logger.debug(f"Program ID detected: {program_id}, need to find clip_id")
# Fetch the program layout to find the clip_id
layout = self._fetch_program_layout(program_id)
if layout:
# Find the video clip in the layout
clip_id = self._find_clip_id_in_program_layout(layout)
if clip_id:
content_id = clip_id
logger.debug(f"Found clip_id {clip_id} from program {program_id}")
else:
logger.error(f"No clip_id found for program {program_id}")
return None
@@ -498,7 +428,6 @@ class RTLPlusProvider(StreamingProvider):
def _fetch_program_layout(self, program_id: str) -> Optional[Dict]:
"""Fetch a program layout by program ID."""
# Ensure we have the right tokens
oauth_token = self.get_user_bearer_token()
if not oauth_token:
logger.error("No user authentication for program layout")
@@ -513,8 +442,6 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to get Bedrock token: {e}")
return None
# Build URL for program layout
# The program ID should be numeric, not with "program_" prefix
clean_id = program_id.replace("program_", "")
url = f"{self.rtl_config.bedrock_layout_base}/program/{clean_id}/layout"
location = f"{self.rtl_config.beta_website}p_{clean_id}-p_{clean_id}"
@@ -543,9 +470,7 @@ class RTLPlusProvider(StreamingProvider):
# Look for video assets in blocks
assets = self.extract_video_assets(layout)
if assets:
# Return the first asset's ID or reference
for asset in assets:
# Look for clip_id in various fields
clip_id = asset.get("clipId") or asset.get("id") or asset.get("reference")
if clip_id:
return clip_id
@@ -575,26 +500,20 @@ class RTLPlusProvider(StreamingProvider):
"""
Get DRM configuration for content.
"""
logger.debug(f"get_drm called with content_id={content_id}")
# Handle combined paths and program IDs similar to get_manifest
if "/" in content_id:
parts = content_id.split("/")
if len(parts) == 2 and parts[0].startswith("program_"):
content_id = parts[1]
logger.debug(f"Extracted clip_id from path for DRM: {content_id}")
# If content_id starts with program_, find the clip_id
if content_id.startswith("program_"):
program_id = content_id[8:]
logger.debug(f"Program ID detected for DRM: {program_id}")
layout = self._fetch_program_layout(program_id)
if layout:
clip_id = self._find_clip_id_in_program_layout(layout)
if clip_id:
content_id = clip_id
logger.debug(f"Found clip_id {clip_id} from program {program_id}")
else:
logger.error(f"No clip_id found for program {program_id} in DRM request")
return []
@@ -623,15 +542,10 @@ class RTLPlusProvider(StreamingProvider):
@staticmethod
def _is_linear_tv_channel(content_id: str) -> bool:
"""Determine if content_id refers to a linear TV channel."""
# VOD clips have patterns like "clip_123456" or contain "clip"
if "clip_" in content_id or content_id.startswith("clip"):
return False
# Also check for common VOD patterns
if content_id.startswith("rrn:") or "/" in content_id:
return False
# Event folder IDs are digits
if content_id.isdigit():
return False
@@ -646,13 +560,8 @@ class RTLPlusProvider(StreamingProvider):
"""
Get manifest URL for VOD/event content using Bedrock layout extraction.
"""
# If content_id doesn't start with "clip_", it might still be a clip
# Make sure we're fetching the video layout, not live layout
if not content_id.startswith("clip_"):
# Try to find if this is a known clip ID pattern
if content_id.isdigit():
# This might be an event or program ID
logger.warning(f"Non-clip content_id for manifest: {content_id}")
if not content_id.startswith("clip_") and content_id.isdigit():
logger.warning(f"Non-clip content_id for manifest: {content_id}")
# Get program context from kwargs (set when navigating from VOD categories)
program_slug = kwargs.get("program_slug")
@@ -660,18 +569,13 @@ class RTLPlusProvider(StreamingProvider):
# Build the correct location header
if program_slug and program_id and content_id.startswith("clip_"):
# Convert clip_1417600 to american-pie-c_1417600
clip_slug = f"{program_slug}-c_{content_id.replace('clip_', '')}"
location = f"{self.rtl_config.beta_website}{program_slug}-p_{program_id}/video/{clip_slug}"
else:
# Fallback - might not work for all content
location = f"{self.rtl_config.beta_website}{content_id}"
logger.debug(f"Fetching video layout for content_id={content_id}, location={location}")
# Fetch the video layout - CRITICAL: use layout_type="video", not "live"!
layout = self.fetch_layout(
layout_type="video", # This must be "video" for VOD content
layout_type="video",
content_id=content_id,
location=location
)
@@ -680,28 +584,23 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to fetch video layout for {content_id}")
return None
# Extract assets using the common method
assets = self.extract_video_assets(layout)
if not assets:
logger.warning(f"No video assets found for {content_id}")
return None
# Select best manifest URL
manifest_url = self.extract_best_manifest_url(assets)
if manifest_url:
logger.info(f"Found manifest URL for {content_id}: {manifest_url[:100]}...")
return manifest_url
if not manifest_url:
logger.warning(f"No suitable manifest URL found for {content_id}")
logger.warning(f"No suitable manifest URL found for {content_id}")
return None
return manifest_url
def _get_drm_vod_or_event(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""
Get DRM configuration for VOD/event content using layout extraction.
"""
# Fetch layout for the content
layout = self.fetch_layout(
layout_type="video",
content_id=content_id,
@@ -712,14 +611,12 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to fetch layout for VOD/event content_id: {content_id}")
return []
# Use the common DRM extraction method
return self.get_drm_for_content(layout)
def get_drm_for_content(self, layout_data: Dict) -> List[DRMConfig]:
"""
Extract DRM configuration from layout data.
"""
# Ensure profile is selected for user-authenticated sessions
if self.authenticator.has_user_credentials():
if not self.authenticator.ensure_profile_selected():
logger.warning("Failed to select profile for DRM request")
@@ -727,7 +624,6 @@ class RTLPlusProvider(StreamingProvider):
assets = self.extract_video_assets(layout_data)
if not assets:
logger.debug("No video assets found in layout")
return []
# Priority: delta provider dashcenc format (best quality)
@@ -739,7 +635,6 @@ class RTLPlusProvider(StreamingProvider):
asset.get("format") == "dashcenc" and
asset.get("quality") == "hd"):
target_asset = asset
logger.debug("Found delta/dashcenc/hd asset")
break
# Fallback: delta + dashcenc + any quality
@@ -747,7 +642,6 @@ class RTLPlusProvider(StreamingProvider):
for asset in assets:
if asset.get("provider") == "delta" and asset.get("format") == "dashcenc":
target_asset = asset
logger.debug(f"Fallback to delta/dashcenc/{asset.get('quality', 'unknown')} asset")
break
# Last resort: any dashcenc asset
@@ -755,14 +649,12 @@ class RTLPlusProvider(StreamingProvider):
for asset in assets:
if asset.get("format") == "dashcenc":
target_asset = asset
logger.debug(f"Last resort: {asset.get('provider', 'unknown')}/dashcenc asset")
break
if not target_asset:
logger.warning("No suitable DRM asset found")
return []
# Extract contentId from the selected asset
drm_info = target_asset.get("drm", {})
drm_config = drm_info.get("config", {})
content_id = drm_config.get("contentId")
@@ -777,7 +669,6 @@ class RTLPlusProvider(StreamingProvider):
logger.error("No user ID available for DRM")
return []
# Get upfront token (works for both Widevine and PlayReady)
upfront_token = self.authenticator.get_upfront_token(
content_id=content_id,
uid=uid,
@@ -787,16 +678,14 @@ class RTLPlusProvider(StreamingProvider):
logger.error(f"Failed to get upfront token for {content_id}")
return []
# Use the shared DRMToday factory to create both configs
drm_configs = create_drmtoday_configs(
upfront_token=upfront_token,
origin=self.rtl_config.beta_website.rstrip("/"),
referer=self.rtl_config.beta_website,
user_agent=self.rtl_config.user_agent,
playready_user_agent=RTLPlusDefaults.PLAYREADY_USER_AGENT, # Edge for PlayReady
playready_user_agent=RTLPlusDefaults.PLAYREADY_USER_AGENT,
)
logger.info(f"Built {len(drm_configs)} DRM configs (Widevine + PlayReady)")
return drm_configs
except Exception as e:
@@ -810,7 +699,6 @@ class RTLPlusProvider(StreamingProvider):
if cached is not None:
data, ts = cached
if (now - ts) < _MANIFEST_CACHE_TTL:
logger.debug(f"RTL+ Manifest cache hit for {content_id}")
return data
# TODO: This legacy endpoint may eventually be migrated to Bedrock
@@ -829,11 +717,8 @@ class RTLPlusProvider(StreamingProvider):
from ...base.auth.base_auth import TokenAuthLevel
current_level = self.authenticator.get_current_token_level()
logger.debug(
f"get_user_bearer_token: current_level={current_level}, has_user_credentials={self.authenticator.has_user_credentials()}")
if current_level == TokenAuthLevel.USER_AUTHENTICATED:
# Ensure profile is selected
self.authenticator.ensure_profile_selected()
return self.authenticator.get_bearer_token()
@@ -10,6 +10,7 @@ provider.get_manifest() / provider.get_drm() methods, which own
the upfront-token flow and all format/quality selection logic.
"""
import json
import re
from typing import Dict, Any, List, Optional, Union
@@ -85,34 +86,28 @@ class RTLPlusVodManager:
"program_<id>/clip_<id>" → direct clip (playable item)
"clip_<id>" → direct clip (playable item)
"""
# NEW: Handle combined paths like "program_68137/clip_1417600"
# Handle combined paths like "program_68137/clip_1417600"
if "/" in content_id:
parts = content_id.split("/")
# Check if this is a program/clip combination
if len(parts) == 2 and parts[0].startswith("program_") and parts[1].startswith("clip_"):
# Extract the clip_id and return it as a direct playable item
clip_id = parts[1]
program_id = parts[0][8:] # Remove "program_" prefix
# For a direct clip, we can fetch and return it immediately
# Since it's a leaf node, return it as a single-entry list
vod_item = self._get_direct_clip_item(clip_id, program_id, **kwargs)
if vod_item:
logger.debug(f"Returning direct clip item: {vod_item.name} (ID: {vod_item.content_id})")
return {"entries": [vod_item], "next_cursor": None, "total": 1}
# Fall through to normal handling
content_id = clip_id
if not content_id:
return self._get_root_category()
if content_id.startswith("folder_"):
folder_id = content_id[7:] # "3"
folder_id = content_id[7:]
return self._get_folder_contents(folder_id, cursor, page_size)
if content_id.startswith("program_"):
program_id = content_id[8:] # "68137" (numeric)
program_id = content_id[8:]
slug = kwargs.get("slug")
return self._get_program_contents(program_id, cursor, page_size, slug=slug)
@@ -132,7 +127,6 @@ class RTLPlusVodManager:
Fetch a direct clip item by its clip_id.
This is used when we have a path like program_68137/clip_1417600.
"""
# Fetch the video layout for this clip
layout = self._provider.fetch_layout(
layout_type="video",
content_id=clip_id,
@@ -150,11 +144,9 @@ class RTLPlusVodManager:
content_type="VOD",
)
# Extract the VodItem from the layout
vod_item = self._extract_vod_item_from_layout(layout, clip_id)
if not vod_item:
# Extract from block items as fallback
vod_item = self._extract_vod_item_from_layout_items(layout, clip_id)
return vod_item
@@ -230,9 +222,8 @@ class RTLPlusVodManager:
def _get_root_category(self) -> Dict[str, Any]:
try:
entries: List[Union[VodCategory, VodItem]] = []
folder_categories = [] # For folder types (main categories)
program_categories = [] # For program types (shows/series)
folder_categories = []
program_categories = []
future_events_count = 0
layout = self._provider.fetch_layout(
@@ -245,13 +236,10 @@ class RTLPlusVodManager:
logger.warning("Failed to fetch home layout for VOD root; returning empty")
return {"entries": [], "next_cursor": None, "total": 0}
blocks = layout.get("blocks", [])
for block in blocks:
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
continue
# Get items safely
content = block.get("content")
if not content:
continue
@@ -260,7 +248,6 @@ class RTLPlusVodManager:
if not items:
continue
# Process each item in the block
for item in items:
if not item:
continue
@@ -274,17 +261,14 @@ class RTLPlusVodManager:
future_events_count += 1
continue
# Try to extract as category first
cat = self._extract_vod_category_from_block_item(item)
if cat:
# Separate folders from programs
if cat.content_id.startswith("folder_"):
folder_categories.append(cat)
else:
program_categories.append(cat)
continue
# If not a category, try as VOD item
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
program_categories.append(vod_item)
@@ -295,8 +279,10 @@ class RTLPlusVodManager:
if future_events_count > 0:
logger.info(f"Filtered out {future_events_count} future events from VOD root")
logger.info(f"Found {len(entries)} entries in root VOD category "
f"({len(folder_categories)} folders, {len(program_categories)} programs)")
logger.info(
f"Found {len(entries)} entries in root VOD category "
f"({len(folder_categories)} folders, {len(program_categories)} programs)"
)
return {
"entries": entries,
@@ -359,16 +345,12 @@ class RTLPlusVodManager:
"""
program_id format: "68137" (numeric)
"""
# For API URL: use numeric ID directly
api_program_id = program_id # "68137"
# For location header: use p_ prefix
seo = slug or f"p_{program_id}"
location = f"{self.cfg.beta_website}{seo}-p_{program_id}"
layout = self._provider.fetch_layout(
layout_type="program",
content_id=api_program_id, # "68137" (no p_ prefix)
content_id=program_id,
location=location,
)
@@ -376,30 +358,15 @@ class RTLPlusVodManager:
logger.error(f"Failed to fetch layout for program {program_id}")
return {"entries": [], "next_cursor": None, "total": 0}
logger.debug(f"Layout type: {type(layout)}")
if not isinstance(layout, dict):
logger.error(f"Layout is not a dict, it's {type(layout)}")
logger.error(f"Unexpected layout type for program {program_id}: {type(layout)}")
return {"entries": [], "next_cursor": None, "total": 0}
blocks = layout.get("blocks", [])
if blocks is None:
logger.error(f"layout.get('blocks') returned None")
blocks = []
logger.debug(f"Program {program_id} has {len(blocks)} blocks")
for i, block in enumerate(blocks):
if not block:
logger.debug(f" Block {i}: is None")
continue
content = block.get("content", {}) if isinstance(block, dict) else {}
content_template = content.get("contentTemplateId") if isinstance(content, dict) else None
items = content.get("items", []) if isinstance(content, dict) else []
logger.debug(f" Block {i}: type={block.get('type')}, template={content_template}, items={len(items)}")
blocks = layout.get("blocks") or []
# FIRST: Check if this is a movie (direct playable video)
movie_item = self._find_direct_video_in_layout(layout)
if movie_item:
logger.debug(f"Program {program_id} is a movie, returning playable item: {movie_item.name}")
return {
"entries": [movie_item],
"next_cursor": None,
@@ -419,11 +386,8 @@ class RTLPlusVodManager:
page = seasons[start: start + page_size]
next_cursor = str(start + page_size) if (start + page_size) < len(seasons) else None
logger.debug(f"Program {program_id} has {len(seasons)} seasons")
return {"entries": page, "next_cursor": next_cursor, "total": len(seasons)}
# No movie and no seasons found
logger.warning(f"No seasons or movie found for program {program_id}")
return {"entries": [], "next_cursor": None, "total": 0}
@@ -433,117 +397,49 @@ class RTLPlusVodManager:
Searches through ALL blocks and items without filtering by template.
"""
if not layout or not isinstance(layout, dict):
logger.error(f"_find_direct_video_in_layout: layout is {type(layout)}")
return None
blocks = layout.get("blocks", [])
if not blocks:
logger.debug("No blocks found in layout")
return None
logger.debug(f"Searching {len(blocks)} blocks for video content")
for block_idx, block in enumerate(blocks):
for block in layout.get("blocks", []):
if not block or not isinstance(block, dict):
logger.debug(f"Block {block_idx} is invalid: {type(block)}")
continue
block_type = block.get("type")
logger.debug(f"Block {block_idx}: type={block_type}")
# Get items from standard location
# Collect items from standard content location
content = block.get("content", {})
if not isinstance(content, dict):
logger.debug(f" Block {block_idx} content is not a dict: {type(content)}")
items = []
else:
items = content.get("items", [])
if items is None:
items = []
items = list(content.get("items", []) or []) if isinstance(content, dict) else []
logger.debug(f" Found {len(items)} items in block.content")
# Also check alternative content for items
# Also check alternativeContent for concurrent blocks
alt_content = block.get("alternativeContent", {})
if isinstance(alt_content, dict):
concurrent_blocks = alt_content.get("concurrentBlocks", [])
if concurrent_blocks:
logger.debug(f" Found {len(concurrent_blocks)} concurrent blocks")
for cb in concurrent_blocks:
if isinstance(cb, dict):
cb_content = cb.get("content", {})
if isinstance(cb_content, dict):
cb_items = cb_content.get("items", [])
if cb_items:
items.extend(cb_items)
logger.debug(f" Added {len(cb_items)} items from concurrent block")
for cb in alt_content.get("concurrentBlocks", []):
if isinstance(cb, dict):
cb_items = cb.get("content", {}).get("items", [])
if cb_items:
items.extend(cb_items)
for item_idx, item in enumerate(items):
for item in items:
if not item or not isinstance(item, dict):
logger.debug(f" Item {item_idx} is invalid: {type(item)}")
continue
item_type = item.get("itemType")
logger.debug(f" Item {item_idx}: itemType={item_type}")
if item_type != "classic":
logger.debug(f" Skipping - not classic type")
if item.get("itemType") != "classic":
continue
item_content = item.get("itemContent", {})
if not item_content or not isinstance(item_content, dict):
logger.debug(f" Skipping - no valid itemContent")
continue
logger.debug(f" itemContent keys: {list(item_content.keys())}")
action = item_content.get("action", {})
logger.debug(f" action type: {type(action)}")
logger.debug(f" action value: {action}")
if isinstance(action, dict):
target = action.get("target", {})
logger.debug(f" target type: {type(target)}")
logger.debug(f" target value: {target}")
if isinstance(target, dict):
value_layout = target.get("value_layout", {})
logger.debug(f" value_layout type: {type(value_layout)}")
logger.debug(f" value_layout value: {value_layout}")
layout_type = value_layout.get("type") if isinstance(value_layout, dict) else None
layout_id = value_layout.get("id") if isinstance(value_layout, dict) else None
logger.debug(f" action.target.value_layout: type={layout_type}, id={layout_id}")
if layout_type == "video":
logger.debug(f" Found video in action.target.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:
# Make sure the content_id is the clip_id, not the program_id
clip_id = value_layout.get("id") # This should be "clip_1417600"
logger.debug(f" Setting VodItem content_id to: {clip_id}")
vod_item.content_id = clip_id # Ensure it's set correctly
# Store program context for potential future use
parent = value_layout.get("parent", {})
# import json
# vod_item.manifest_script = json.dumps({
# "program_id": parent.get("id"),
# "program_slug": parent.get("seo"),
# "clip_id": clip_id
# })
logger.debug(
f" Successfully extracted VodItem: {vod_item.name} (ID: {vod_item.content_id})")
clip_id = value_layout.get("id")
vod_item.content_id = clip_id
return vod_item
else:
logger.debug(f" Failed to extract VodItem from item")
else:
logger.debug(f" action is not a dict, it's {type(action)}")
# Check direct itemContent type
if item_content.get("type") == "video":
logger.debug(f" Found video in itemContent.type")
clip_id = item_content.get("id")
if clip_id:
vod_item = VodItem.create_episode(
@@ -553,15 +449,6 @@ class RTLPlusVodManager:
)
vod_item.description = item_content.get("description")
vod_item.logo_url = self._extract_thumbnail(item_content)
# Store program context
# import json
# vod_item.manifest_script = json.dumps({
# "clip_id": clip_id
# })
logger.debug(
f" Created VodItem from direct video: {vod_item.name} (ID: {vod_item.content_id})")
return vod_item
# Check alternative action locations
@@ -572,17 +459,13 @@ class RTLPlusVodManager:
if isinstance(alt_target, dict):
alt_value = alt_target.get("value_layout", {})
if isinstance(alt_value, dict) and alt_value.get("type") == "video":
logger.debug(f" Found video in {action_key}")
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
# Make sure content_id is the clip_id
clip_id = alt_value.get("id")
if clip_id:
vod_item.content_id = clip_id
logger.debug(f" Setting VodItem content_id from {action_key} to: {clip_id}")
return vod_item
logger.debug("No video content found in any block")
return None
def _extract_seasons_from_layout(self, layout: Dict) -> List[VodCategory]:
@@ -600,7 +483,6 @@ class RTLPlusVodManager:
alternative_content = block.get("alternativeContent")
if alternative_content:
for cb in alternative_content.get("concurrentBlocks", []):
# Get season info from concurrent block
season_title = cb.get("title", "Unbekannte Staffel")
block_id = cb.get("id")
@@ -743,14 +625,11 @@ class RTLPlusVodManager:
if value_layout.get("type") == "video":
clip_id = value_layout.get("id")
# Get program context from parent if available
parent = value_layout.get("parent", {})
program_id = parent.get("id")
program_slug = parent.get("seo")
logger.debug(f" Extracted clip_id: {clip_id}, program_id: {program_id}, program_slug: {program_slug}")
elif item_content.get("type") == "video":
clip_id = item_content.get("id")
logger.debug(f" Extracted clip_id from itemContent: {clip_id}")
# Try alternative action locations
if not clip_id:
@@ -762,16 +641,13 @@ class RTLPlusVodManager:
alt_value = alt_target.get("value_layout", {})
if isinstance(alt_value, dict) and alt_value.get("type") == "video":
clip_id = alt_value.get("id")
# Get program context from parent
parent = alt_value.get("parent", {})
program_id = parent.get("id")
program_slug = parent.get("seo")
logger.debug(f" Extracted clip_id from {action_key}: {clip_id}")
if clip_id:
break
if not clip_id:
logger.debug(f" No clip_id found in item")
return None
# Get name with fallback
@@ -785,14 +661,9 @@ class RTLPlusVodManager:
elif extra_title:
full_title = extra_title
else:
# Try to get from highlight or other fields
highlight = item_content.get("highlight", "")
if highlight:
# Clean up highlight (remove date info)
if "" in highlight:
full_title = highlight.split("")[0].strip()
else:
full_title = highlight
full_title = highlight.split("")[0].strip() if "" in highlight else highlight
else:
full_title = f"Unbekanntes Video ({clip_id})"
@@ -801,12 +672,10 @@ class RTLPlusVodManager:
episode_number: Optional[int] = None
if highlight:
# Look for season/folge patterns
season_match = re.search(r"Staffel\s*(\d+)", highlight, re.IGNORECASE)
if season_match:
season_number = int(season_match.group(1))
else:
# Try alternative: "S01" pattern
season_match = re.search(r"S(\d+)", highlight, re.IGNORECASE)
if season_match:
season_number = int(season_match.group(1))
@@ -815,15 +684,13 @@ class RTLPlusVodManager:
if episode_match:
episode_number = int(episode_match.group(1))
else:
# Try alternative: "E01" pattern
episode_match = re.search(r"E(\d+)", highlight, re.IGNORECASE)
if episode_match:
episode_number = int(episode_match.group(1))
# Create VodItem with the clip_id as content_id
vod_item = VodItem.create_episode(
name=full_title,
content_id=clip_id, # This should be "clip_1417600"
content_id=clip_id,
provider=self._provider.provider_name,
season_number=season_number if season_number is not None else -1,
episode_number=episode_number if episode_number is not None else -1,
@@ -833,17 +700,14 @@ class RTLPlusVodManager:
vod_item.duration_seconds = self._extract_duration(item_content)
vod_item.progress = item_content.get("progress", 0)
# Store program context in manifest_script for later use (e.g., manifest fetching)
# Store program context for manifest fetching
if program_id or program_slug:
import json
vod_item.manifest_script = json.dumps({
"program_id": program_id,
"program_slug": program_slug,
"clip_id": clip_id
})
logger.debug(f" Stored program context: program_id={program_id}, program_slug={program_slug}")
logger.debug(f" Created VodItem: '{vod_item.name}' with content_id='{vod_item.content_id}'")
return vod_item
def _extract_vod_category_from_block_item(self, item: Dict) -> Optional[VodCategory]:
@@ -874,38 +738,31 @@ class RTLPlusVodManager:
# Store with type prefix for clarity
if layout_type == "folder":
content_id = f"folder_{content_id}" # folder_3
content_id = f"folder_{content_id}"
elif layout_type == "program":
content_id = f"program_{content_id}" # program_68137
content_id = f"program_{content_id}"
# Capture the SEO slug from the API (e.g. "american-pie")
seo_slug = value_layout.get("seo") or ""
# Get name with fallbacks
name = item_content.get("title")
# If no title, try to get from folder mapping or other fields
if not name and layout_type == "folder":
if content_id in FOLDER_NAMES:
name = FOLDER_NAMES[content_id]
else:
seo = value_layout.get("seo", "")
if seo:
name = seo.replace("-", " ").title()
else:
name = f"Kategorie {content_id}"
name = seo.replace("-", " ").title() if seo else f"Kategorie {content_id}"
elif not name:
name = item_content.get("extraTitle") or item_content.get("highlight")
if name:
if "" in str(name):
name = name.split("")[0].strip()
if name and "" in str(name):
name = name.split("")[0].strip()
if not name:
name = f"Unbekannt {layout_type}"
return VodCategory(
name=name,
content_id=content_id, # "folder_3" or "program_68137"
content_id=content_id,
provider=self._provider.provider_name,
logo_url=self._extract_thumbnail(item_content),
description=item_content.get("description") or item_content.get("highlight"),
@@ -965,25 +822,21 @@ class RTLPlusVodManager:
if not highlight:
return False
import re
from datetime import datetime
# Check for German date patterns with future dates
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
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())
# Convert 2-digit year to 4-digit
if year < 100:
year = 2000 + year
try:
event_date = datetime(year, month, day, hour, minute)
# If date is in the future, this is an upcoming event
if event_date > datetime.now():
return True
except ValueError:
@@ -997,10 +850,8 @@ class RTLPlusVodManager:
if not highlight:
return False
# Check if it has date/time info
has_datetime = bool(re.search(r"\d{2}\.\d{2}\.\d{2}", highlight))
# Check action target type
action = item_content.get("action", {})
target = action.get("target", {})
value_layout = target.get("value_layout", {})
@@ -1024,7 +875,6 @@ class RTLPlusVodManager:
if content_id.startswith("program_"):
program_id = content_id[len("program_"):]
# Program layouts are cached under "program:<id>:<page>:<nb>"
cache_key = (
f"program:{program_id}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}"
@@ -1052,8 +902,4 @@ class RTLPlusVodManager:
else:
# Unknown prefix — wipe everything rather than silently doing nothing
logger.debug(
f"invalidate_cache: unknown content_id format {content_id!r}, "
"clearing full layout cache"
)
self._provider.invalidate_layout_cache()