Files
script.service.ultimate/lib/streaming_providers/providers/rtlplus/vod_manager.py
T

1648 lines
68 KiB
Python
Raw Normal View History

2026-03-09 17:22:42 +01:00
# streaming_providers/providers/rtlplus/vod_manager.py
"""
RTL+ VOD Manager
2026-04-29 10:37:18 +02:00
Handles Video on Demand content using the Bedrock layout API.
Follows the same patterns as channel_manager and event_manager.
Manifest and DRM resolution is always delegated to the central
provider.get_manifest() / provider.get_drm() methods, which own
the upfront-token flow and all format/quality selection logic.
2026-03-09 17:22:42 +01:00
"""
2026-05-03 18:21:00 +02:00
import json
2026-04-29 12:33:55 +02:00
import re
2026-05-07 13:16:59 +02:00
from datetime import datetime
from typing import Dict, Any, List, Optional, Union, Tuple
from zoneinfo import ZoneInfo # Python 3.9+; fallback to pytz if needed
2026-03-09 17:22:42 +01:00
from ...base.models.vod import VodCategory, VodItem
2026-04-29 10:37:18 +02:00
from ...base.models import DRMConfig
2026-03-09 17:22:42 +01:00
from ...base.utils.logger import logger
2026-04-29 10:37:18 +02:00
from .constants import RTLPlusDefaults
2026-05-08 09:54:08 +02:00
from .layout_helpers import (
unwrap_target,
parse_german_datetime,
build_image_url,
extract_thumbnail,
extract_thumbnail_from_layout,
)
2026-03-09 17:22:42 +01:00
2026-04-29 12:33:55 +02:00
FOLDER_NAMES = {
"4": "Filme",
"3": "Serien",
"1": "Reality & Shows",
"148": "Kids",
"165": "Anime",
"6": "Sport",
"55": "Themenwelten",
2026-04-29 12:33:55 +02:00
}
2026-03-09 17:22:42 +01:00
2026-04-29 16:48:02 +02:00
2026-03-09 17:22:42 +01:00
class RTLPlusVodManager:
"""
2026-04-29 10:37:18 +02:00
Manages VOD content for RTL+.
Navigation hierarchy:
root
2026-05-01 21:52:04 +02:00
└── folder_<id> (Bedrock folder, e.g. folder_3 = Serien)
└── program_<id> (program layout → seasons or direct video)
2026-05-07 20:38:16 +02:00
├── month_<program_id>::<block_id> (monthly archive selector)
│ └── episodes (via /program/{id}/block/{block_id} fetch)
2026-05-07 13:16:59 +02:00
├── season_<block_id> (numbered season selector)
│ └── episodes (via block fetch)
└── clip_id (video layout, playable)
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
Manifest and DRM for playable items are always resolved through
the provider's central get_manifest() / get_drm() methods.
2026-03-09 17:22:42 +01:00
"""
def __init__(self, provider):
self._provider = provider
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Convenience accessors
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
@property
2026-04-29 10:37:18 +02:00
def cfg(self):
2026-03-09 17:22:42 +01:00
return self._provider.rtl_config
@property
2026-04-29 10:37:18 +02:00
def http(self):
return self._provider.http_manager
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
@property
def auth(self):
return self._provider.authenticator
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Public API (VodOperations interface)
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
def get_vod_category(
2026-04-29 16:48:02 +02:00
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs,
2026-04-29 10:37:18 +02:00
) -> Dict[str, Any]:
2026-03-09 17:22:42 +01:00
"""
2026-04-29 10:37:18 +02:00
Return the children of a VOD node.
2026-05-07 13:16:59 +02:00
Supported content_id formats:
- "" → root
- "folder_<id>" → Bedrock folder
- "program_<id>" → program/series (handles movies, numbered seasons, monthly archives)
- "season_<id>" → numbered season block (Staffel 1, 2, etc.)
2026-05-07 20:38:16 +02:00
- "month_<program_id>::<block_id>" → monthly archive block (2026-05, 2026-04, etc.)
2026-05-07 13:16:59 +02:00
- "clip_<id>" → direct clip (playable item)
- "program_<id>/clip_<id>" → explicit program+clip path
2026-03-09 17:22:42 +01:00
"""
2026-05-03 18:21:00 +02:00
# Handle combined paths like "program_68137/clip_1417600"
2026-05-02 18:32:49 +02:00
if "/" in content_id:
parts = content_id.split("/")
if len(parts) == 2 and parts[0].startswith("program_") and parts[1].startswith("clip_"):
clip_id = parts[1]
program_id = parts[0][8:] # Remove "program_" prefix
vod_item = self._get_direct_clip_item(clip_id, program_id, **kwargs)
if vod_item:
return {"entries": [vod_item], "next_cursor": None, "total": 1}
content_id = clip_id
2026-05-07 13:16:59 +02:00
# Handle direct clip ID
if content_id.startswith("clip_"):
vod_item = self.get_vod_item_info(content_id)
if vod_item:
return {"entries": [vod_item], "next_cursor": None, "total": 1}
return {"entries": [], "next_cursor": None, "total": 0}
2026-05-07 20:38:16 +02:00
# Handle month block (monthly episode archives) - NEW FORMAT with program_id
2026-05-07 13:16:59 +02:00
if content_id.startswith("month_"):
2026-05-07 20:38:16 +02:00
rest = content_id[6:] # Remove "month_" prefix
if "::" in rest:
program_id, block_id = rest.split("::", 1)
return self._get_program_block_episodes(program_id, block_id, cursor, page_size)
# Legacy fallback (shouldn't occur after fix)
return self._get_block_episodes(rest, cursor, page_size, block_type="month")
2026-05-07 13:16:59 +02:00
# Handle numbered season block
if content_id.startswith("season_"):
block_id = content_id[7:] # Remove "season_" prefix
return self._get_block_episodes(block_id, cursor, page_size, block_type="season")
# Handle episodes_current_ prefix (special cursor for current month preview)
if content_id.startswith("episodes_current_"):
program_id = content_id.replace("episodes_current_", "")
return self._get_current_episodes_preview(program_id, cursor, page_size)
2026-03-18 19:14:50 +01:00
if not content_id:
2026-04-29 10:37:18 +02:00
return self._get_root_category()
2026-03-18 19:14:50 +01:00
2026-05-01 21:52:04 +02:00
if content_id.startswith("folder_"):
2026-05-03 18:21:00 +02:00
folder_id = content_id[7:]
2026-04-29 10:37:18 +02:00
return self._get_folder_contents(folder_id, cursor, page_size)
2026-03-18 19:14:50 +01:00
2026-05-01 21:52:04 +02:00
if content_id.startswith("program_"):
2026-05-03 18:21:00 +02:00
program_id = content_id[8:]
2026-05-01 13:20:31 +02:00
slug = kwargs.get("slug")
return self._get_program_contents(program_id, cursor, page_size, slug=slug)
2026-05-01 12:16:49 +02:00
# Bare numeric ID → treat as program (backward compatibility)
if content_id.isdigit():
2026-05-01 21:52:04 +02:00
return self._get_program_contents(content_id, cursor, page_size)
2026-05-01 12:16:49 +02:00
2026-04-29 10:37:18 +02:00
logger.warning(f"Unrecognised VOD content_id format: {content_id!r}")
return {"entries": [], "next_cursor": None, "total": 0}
2026-03-09 17:22:42 +01:00
2026-05-12 15:35:18 +02:00
def search_vod(
self,
query: str,
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs,
) -> Dict[str, Any]:
"""
Search the RTL+ catalogue for programs and clips matching *query*.
Uses the Bedrock ``frontspace/search/layout`` endpoint, which returns
the same block/item structure as every other layout response. Results
are split into typed blocks (e.g. "Serien & Shows", "Filme") but we
flatten them into a single list because the caller's UI shows one
combined result set.
Pagination:
The Bedrock search endpoint accepts ``blockPage`` (1-indexed).
We map *cursor* → ``blockPage`` using the same integer-cursor
convention used by the rest of the VOD manager.
Args:
query: Free-text search string.
cursor: Opaque page token (stringified integer, None = page 1).
page_size: Ignored — the API controls page size; kept for
interface compatibility.
Returns:
``{"entries": [...], "next_cursor": str | None, "total": int}``
"""
if not query or not query.strip():
return {"entries": [], "next_cursor": None, "total": 0}
block_page = 1
if cursor:
try:
block_page = int(cursor)
except ValueError:
block_page = 1
layout = self._provider.fetch_layout(
layout_type="search",
content_id="search", # unused by the search branch but
# required by fetch_layout signature
block_page=block_page,
nb_pages=RTLPlusDefaults.DEFAULT_NB_PAGES,
query=query,
)
if not layout:
logger.warning(f"RTL+ search returned no layout for query={query!r}")
return {"entries": [], "next_cursor": None, "total": 0}
return self._parse_search_layout(layout, block_page)
# ------------------------------------------------------------------
# Search-specific parsing helpers
# ------------------------------------------------------------------
def _parse_search_layout(
self,
layout: Dict,
current_page: int,
) -> Dict[str, Any]:
"""
Flatten all ``bffPaginated`` blocks from a search layout into a
single list of VodCategory / VodItem entries.
The search response uses the identical block→item structure as folder
and program layouts, so we reuse the existing extraction helpers
directly. Items whose action target resolves to a ``program`` layout
become VodCategory objects (browsable); items pointing to a ``video``
layout become VodItem objects (playable).
Pagination is block-level: the API returns one or more blocks per
page; we advance the page when *any* block reports a next page.
"""
entries: list = []
has_next_page = False
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
continue
content = block.get("content") or {}
items = content.get("items") or []
for item in items:
if not item or item.get("itemType") != "classic":
continue
item_content = item.get("itemContent") or {}
# Skip future scheduled events (same filter as VOD root)
if self._is_future_event(item_content):
continue
entry = self._extract_search_entry(item)
if entry is not None:
entries.append(entry)
# Detect pagination: does this block have a next page?
pagination = content.get("pagination") or {}
if pagination.get("nextPage"):
has_next_page = True
next_cursor = str(current_page + 1) if has_next_page else None
logger.debug(
f"RTL+ search page {current_page}: "
f"{len(entries)} entries, next_cursor={next_cursor!r}"
)
return {
"entries": entries,
"next_cursor": next_cursor,
"total": len(entries), # Bedrock search does not expose a grand total
}
def _extract_search_entry(self, item: Dict):
"""
Convert one search result item into a VodCategory or VodItem.
Decision tree (mirrors the rest of the manager):
- target.type == "program" → VodCategory (series / show landing page)
- target.type == "video" → VodItem (directly playable clip)
- target.type == "folder" → VodCategory (genre folder)
- anything else → None (silently skipped)
We deliberately reuse ``_extract_vod_category_from_block_item`` and
``_extract_vod_item_from_block_item`` so that any future improvements
to those methods automatically benefit search results too.
"""
item_content = item.get("itemContent") or {}
action = item_content.get("action") or {}
target = unwrap_target(action.get("target") or {})
layout_type = target.get("value_layout", {}).get("type")
if layout_type in ("program", "folder"):
return self._extract_vod_category_from_block_item(item)
if layout_type == "video":
return self._extract_vod_item_from_block_item(item)
# Unrecognised layout type — skip gracefully
logger.debug(f"RTL+ search: skipping item with layout_type={layout_type!r}")
return None
2026-05-07 20:38:16 +02:00
def _get_program_block_episodes(
self,
program_id: str,
block_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
) -> Dict[str, Any]:
"""
Fetch episodes from a month block using the /program/{id}/block/{block_id} endpoint.
The block_id must be the full page_... ID from concurrentBlocks.
"""
page = 1
if cursor:
try:
page = int(cursor)
except ValueError:
page = 1
oauth_token = self._provider.get_user_bearer_token() or self.auth.get_bearer_token()
if not oauth_token:
logger.error("No OAuth token available for program block episodes")
return {"entries": [], "next_cursor": None, "total": 0}
bedrock_token = self.auth.get_bedrock_token()
if not bedrock_token:
logger.error("No Bedrock token available for program block episodes")
return {"entries": [], "next_cursor": None, "total": 0}
location = f"{self.cfg.base_website}p_{program_id}-p_{program_id}"
url = f"{self.cfg.bedrock_layout_base}/program/{program_id}/block/{block_id}"
params = {
"nbPages": RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES,
"page": page,
}
headers = self.cfg.get_layout_headers(oauth_token, bedrock_token, location)
try:
response = self.http.get(url, headers=headers, params=params, operation="api")
response.raise_for_status()
layout = response.json()
except Exception as e:
logger.error(f"Failed to fetch program block {block_id} for program {program_id}: {e}")
return {"entries": [], "next_cursor": None, "total": 0}
episodes: List[VodItem] = []
for item in layout.get("content", {}).get("items", []):
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
episodes.append(vod_item)
pagination = layout.get("content", {}).get("pagination", {})
total = pagination.get("totalItems", len(episodes))
next_page = pagination.get("nextPage")
next_cursor = str(next_page) if next_page else None
logger.debug(f"Fetched {len(episodes)}/{total} episodes from program {program_id} block {block_id} (page {page})")
return {"entries": episodes, "next_cursor": next_cursor, "total": total}
2026-05-07 13:16:59 +02:00
def _get_block_episodes(
self,
block_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
block_type: str = "block"
) -> Dict[str, Any]:
"""
2026-05-07 20:38:16 +02:00
Fetch episodes from a block (works for numbered seasons).
2026-05-07 13:16:59 +02:00
Args:
block_id: The block ID (e.g., "c7916bb9-d111-49db-9a99-ab40ef6413da_39638")
cursor: Page number (1-indexed)
page_size: Number of items per page (unused, API controls this)
block_type: Type of block for logging ("season" or "month")
"""
page = 1
if cursor:
try:
page = int(cursor)
except ValueError:
page = 1
2026-05-07 20:38:16 +02:00
# Use generic block endpoint for numbered seasons
2026-05-07 13:16:59 +02:00
layout = self._provider.fetch_layout(
layout_type="block",
content_id=block_id,
block_page=page,
nb_pages=RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES,
)
if not layout:
logger.error(f"Failed to fetch {block_type} block layout for {block_id}")
return {"entries": [], "next_cursor": None, "total": 0}
episodes: List[VodItem] = []
for item in layout.get("content", {}).get("items", []):
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
episodes.append(vod_item)
pagination = layout.get("content", {}).get("pagination", {})
total = pagination.get("totalItems", len(episodes))
next_page = pagination.get("nextPage")
next_cursor = str(next_page) if next_page else None
logger.debug(f"Fetched {len(episodes)}/{total} episodes from {block_type} block (page {page})")
return {
"entries": episodes,
"next_cursor": next_cursor,
"total": total,
}
2026-05-07 14:59:03 +02:00
def _get_current_episodes_preview(self, program_id: str, cursor: Optional[str] = None, page_size: int = 24) -> Dict[
str, Any]:
"""Get paginated current episodes for the program preview."""
2026-05-07 13:16:59 +02:00
seo = f"p_{program_id}"
location = f"{self.cfg.base_website}{seo}-p_{program_id}"
layout = self._provider.fetch_layout(
layout_type="program",
content_id=program_id,
location=location,
)
if not layout:
return {"entries": [], "next_cursor": None, "total": 0}
2026-05-07 14:59:03 +02:00
2026-05-07 20:38:16 +02:00
_, current_episodes = self._extract_season_selector_with_episodes(layout, program_id)
2026-05-07 14:59:03 +02:00
if not current_episodes:
return {"entries": [], "next_cursor": None, "total": 0}
2026-05-07 13:16:59 +02:00
page = int(cursor) if cursor and cursor.isdigit() else 1
paginated, next_cursor = self._paginate_episodes(current_episodes, page, page_size)
return {"entries": paginated, "next_cursor": next_cursor, "total": len(current_episodes)}
2026-05-02 18:32:49 +02:00
def _get_direct_clip_item(self, clip_id: str, program_id: Optional[str] = None, **kwargs) -> Optional[VodItem]:
"""
Fetch a direct clip item by its clip_id.
This is used when we have a path like program_68137/clip_1417600.
"""
layout = self._provider.fetch_layout(
layout_type="video",
content_id=clip_id,
2026-05-05 10:32:42 +02:00
location=f"{self.cfg.base_website}{clip_id}",
2026-05-02 18:32:49 +02:00
)
if not layout:
logger.warning(f"Failed to fetch layout for direct clip {clip_id}")
# Fallback: create a basic VodItem with what we know
return VodItem(
name=kwargs.get("item_name", f"Video {clip_id}"),
content_id=clip_id,
provider=self._provider.provider_name,
mode="vod",
content_type="VOD",
)
vod_item = self._extract_vod_item_from_layout(layout, clip_id)
if not vod_item:
vod_item = self._extract_vod_item_from_layout_items(layout, clip_id)
return vod_item
def _extract_vod_item_from_layout_items(self, layout: Dict, clip_id: str) -> Optional[VodItem]:
"""Extract VodItem from layout items (fallback method)."""
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
continue
for item in block.get("content", {}).get("items", []):
if item.get("itemType") != "classic":
continue
item_content = item.get("itemContent", {})
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-05-08 09:54:08 +02:00
target = unwrap_target(action.get("target", {}))
2026-05-02 18:32:49 +02:00
value_layout = target.get("value_layout", {})
if value_layout.get("type") == "video" and value_layout.get("id") == clip_id:
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
return vod_item
# Also check direct itemContent
if item_content.get("type") == "video" and item_content.get("id") == clip_id:
return VodItem.create_episode(
name=item_content.get("title", clip_id),
content_id=clip_id,
provider=self._provider.provider_name,
2026-05-07 13:16:59 +02:00
season_number=-1,
episode_number=-1,
2026-05-02 18:32:49 +02:00
)
return None
2026-04-29 10:37:18 +02:00
def get_vod_item_info(self, clip_id: str) -> Optional[VodItem]:
2026-03-09 17:22:42 +01:00
"""
2026-04-29 10:37:18 +02:00
Return metadata for a single playable clip.
Does NOT fetch manifest/DRM — callers use get_manifest/get_drm for that.
2026-03-09 17:22:42 +01:00
"""
2026-04-29 10:37:18 +02:00
layout = self._provider.fetch_layout(
layout_type="video",
content_id=clip_id,
2026-05-05 10:32:42 +02:00
location=f"{self.cfg.base_website}{clip_id}",
2026-03-09 17:22:42 +01:00
)
2026-04-29 10:37:18 +02:00
if not layout:
return None
return self._extract_vod_item_from_layout(layout, clip_id)
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
def get_manifest_for_video(self, clip_id: str) -> Optional[str]:
2026-05-07 13:16:59 +02:00
"""Resolve the playback manifest URL for a VOD clip."""
2026-04-29 10:37:18 +02:00
return self._provider.get_manifest(clip_id)
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
def get_drm_for_video(self, clip_id: str) -> List[DRMConfig]:
2026-05-07 13:16:59 +02:00
"""Resolve DRM configuration (Widevine + PlayReady) for a VOD clip."""
2026-04-29 10:37:18 +02:00
return self._provider.get_drm(clip_id)
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Root category — parsed from home layout
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
def _get_root_category(self) -> Dict[str, Any]:
2026-04-29 11:21:30 +02:00
try:
2026-05-03 18:21:00 +02:00
folder_categories = []
program_categories = []
2026-04-29 12:33:55 +02:00
future_events_count = 0
2026-03-09 17:22:42 +01:00
2026-04-29 11:21:30 +02:00
layout = self._provider.fetch_layout(
layout_type="alias",
content_id="home",
2026-05-05 10:32:42 +02:00
location=f"{self.cfg.base_website}",
2026-04-29 11:21:30 +02:00
)
2026-04-29 11:13:06 +02:00
2026-04-29 11:21:30 +02:00
if not layout:
logger.warning("Failed to fetch home layout for VOD root; returning empty")
return {"entries": [], "next_cursor": None, "total": 0}
2026-03-09 17:22:42 +01:00
2026-05-03 18:21:00 +02:00
for block in layout.get("blocks", []):
2026-04-29 11:21:30 +02:00
if block.get("type") != "bffPaginated":
continue
2026-04-29 11:13:06 +02:00
2026-04-29 11:32:44 +02:00
content = block.get("content")
if not content:
continue
items = content.get("items")
2026-04-29 11:21:30 +02:00
if not items:
continue
2026-04-29 11:13:06 +02:00
for item in items:
2026-04-29 11:32:44 +02:00
if not item:
continue
2026-04-29 12:33:55 +02:00
item_content = item.get("itemContent")
if not item_content:
continue
# Skip future events
if self._is_future_event(item_content):
future_events_count += 1
continue
2026-04-29 11:21:30 +02:00
cat = self._extract_vod_category_from_block_item(item)
if cat:
2026-05-01 21:52:04 +02:00
if cat.content_id.startswith("folder_"):
2026-04-29 12:33:55 +02:00
folder_categories.append(cat)
else:
program_categories.append(cat)
2026-04-29 11:21:30 +02:00
continue
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
2026-04-29 12:33:55 +02:00
program_categories.append(vod_item)
2026-04-29 11:21:30 +02:00
# Keep only the hardcoded folders, in the order they are defined in FOLDER_NAMES.
folder_by_id = {
cat.content_id[len("folder_"):]: cat
for cat in folder_categories
if cat.content_id.startswith("folder_")
}
# Inject any FOLDER_NAMES entries missing from the home layout (e.g. Themenwelten,
# which lives in a submenu and is not surfaced on the home page).
for folder_id, folder_name in FOLDER_NAMES.items():
if folder_id not in folder_by_id:
folder_by_id[folder_id] = VodCategory(
name=folder_name,
content_id=f"folder_{folder_id}",
provider=self._provider.provider_name,
)
logger.debug(f"Injected missing root folder: {folder_name} (id={folder_id})")
entries = [
folder_by_id[folder_id]
for folder_id in FOLDER_NAMES
if folder_id in folder_by_id
]
2026-04-29 11:21:30 +02:00
2026-04-29 12:33:55 +02:00
if future_events_count > 0:
logger.info(f"Filtered out {future_events_count} future events from VOD root")
2026-05-03 18:21:00 +02:00
logger.info(
f"Found {len(entries)} entries in root VOD category "
f"(restricted to {len(FOLDER_NAMES)} hardcoded folders, "
f"{len(program_categories)} programs discarded)"
2026-05-03 18:21:00 +02:00
)
2026-04-29 11:32:44 +02:00
2026-04-29 11:21:30 +02:00
return {
2026-04-29 12:33:55 +02:00
"entries": entries,
2026-04-29 11:21:30 +02:00
"next_cursor": None,
2026-04-29 12:33:55 +02:00
"total": len(entries),
2026-04-29 11:21:30 +02:00
}
except Exception as e:
logger.error(f"Error in _get_root_category: {e}", exc_info=True)
return {"entries": [], "next_cursor": None, "total": 0}
2026-03-09 17:22:42 +01:00
2026-03-10 13:04:11 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Folder → program listing
2026-03-10 13:04:11 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
def _get_folder_contents(
2026-04-29 16:48:02 +02:00
self,
folder_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
2026-04-29 10:37:18 +02:00
) -> Dict[str, Any]:
2026-03-10 13:04:11 +01:00
"""
2026-04-29 10:37:18 +02:00
Return the programs/sub-folders inside a Bedrock folder.
2026-03-10 13:04:11 +01:00
"""
2026-04-29 10:37:18 +02:00
layout = self._provider.fetch_layout(
layout_type="folder",
content_id=folder_id,
2026-03-10 13:04:11 +01:00
)
2026-05-08 12:49:06 +02:00
if not layout or not isinstance(layout, dict):
logger.error(f"Invalid layout for folder {folder_id}")
2026-04-29 10:37:18 +02:00
return {"entries": [], "next_cursor": None, "total": 0}
2026-03-10 13:04:11 +01:00
2026-04-29 10:37:18 +02:00
entries: List[Union[VodCategory, VodItem]] = []
2026-05-08 12:49:06 +02:00
2026-04-29 10:37:18 +02:00
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
2026-03-09 17:22:42 +01:00
continue
2026-05-08 12:49:06 +02:00
# Skip blocks without items
items = block.get("content", {}).get("items", [])
if not items:
continue
for item in items:
if not item or item.get("itemType") != "classic":
continue
item_content = item.get("itemContent")
if not item_content:
continue
# Check if this item has a valid action target
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-05-08 12:49:06 +02:00
if not action:
# Skip items without actions (like banner images)
continue
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
# Skip if no valid layout type
if not value_layout.get("type"):
continue
# Try to extract as VodItem first (if it has video assets)
2026-04-29 10:37:18 +02:00
if self._item_has_video_assets(item):
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
entries.append(vod_item)
else:
2026-05-08 12:49:06 +02:00
# Extract as category (folder or program)
2026-04-29 10:37:18 +02:00
cat = self._extract_vod_category_from_block_item(item)
if cat:
entries.append(cat)
2026-03-09 17:22:42 +01:00
# Deduplicate by content_id, preserving first occurrence
seen_ids: set = set()
unique_entries = []
for e in entries:
if e.content_id not in seen_ids:
seen_ids.add(e.content_id)
unique_entries.append(e)
entries = unique_entries
entries.sort(key=lambda e: (e.name or "").lower())
logger.info(f"Folder {folder_id} returned {len(entries)} entries (alphabetically sorted)")
2026-04-29 10:37:18 +02:00
return {"entries": entries, "next_cursor": None, "total": len(entries)}
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-05-07 13:16:59 +02:00
# Program contents (handles movies, numbered seasons, monthly archives)
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-05-11 16:37:40 +02:00
def _get_program_contents(
self,
program_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
slug: Optional[str] = None,
) -> Dict[str, Any]:
"""
Get program contents - handles movies, series with season selectors.
"""
2026-05-01 13:20:31 +02:00
seo = slug or f"p_{program_id}"
2026-05-05 10:32:42 +02:00
location = f"{self.cfg.base_website}{seo}-p_{program_id}"
2026-05-11 16:37:40 +02:00
2026-04-29 10:37:18 +02:00
layout = self._provider.fetch_layout(
2026-05-11 16:37:40 +02:00
layout_type="program",
content_id=program_id,
location=location,
2026-04-29 16:31:27 +02:00
)
2026-05-11 16:37:40 +02:00
2026-04-29 10:37:18 +02:00
if not layout:
2026-04-29 19:53:08 +02:00
logger.error(f"Failed to fetch layout for program {program_id}")
2026-04-29 10:37:18 +02:00
return {"entries": [], "next_cursor": None, "total": 0}
2026-03-09 17:22:42 +01:00
2026-05-11 16:37:40 +02:00
# FIRST: Check for monthly archive selector (series with monthly episodes)
2026-05-07 20:38:16 +02:00
season_selector, current_episodes = self._extract_season_selector_with_episodes(layout, program_id)
2026-05-11 16:37:40 +02:00
# SECOND: Check for numbered seasons (plain CardListM blocks not in the selector).
# Always run — some programs have BOTH a monthly selector AND separate numbered
# season blocks (e.g. Staffel 13/14 sitting outside the dropdown).
numbered_seasons = self._extract_seasons_from_layout(layout, program_id)
2026-05-11 15:17:57 +02:00
if season_selector or numbered_seasons:
2026-05-11 16:37:40 +02:00
# Merge: selector entries first, then any numbered seasons not already present.
2026-05-11 15:17:57 +02:00
seen = {c.content_id for c in season_selector}
for s in numbered_seasons:
if s.content_id not in seen:
season_selector.append(s)
seen.add(s.content_id)
2026-05-11 16:37:40 +02:00
logger.debug(
f"Found {len(season_selector)} total season entries for program {program_id} "
f"({len(numbered_seasons)} from plain blocks)"
2026-05-11 15:17:57 +02:00
)
2026-05-11 16:37:40 +02:00
return self._handle_series_response(program_id, season_selector, current_episodes, cursor, page_size)
2026-05-07 13:57:31 +02:00
2026-05-07 14:37:03 +02:00
# THIRD: No seasons found - this is likely a movie or direct video
movie_item = self._find_direct_video_in_layout(layout, is_series=False)
2026-04-29 16:48:02 +02:00
if movie_item:
2026-05-07 14:37:03 +02:00
# Enhance movie metadata from layout
2026-05-03 20:47:12 +02:00
layout_title = (
2026-05-07 13:16:59 +02:00
layout.get("entity", {}).get("metadata", {}).get("title")
or layout.get("seo", {}).get("title")
2026-05-03 20:47:12 +02:00
)
2026-05-07 14:37:03 +02:00
if layout_title and layout_title != movie_item.name:
2026-05-03 20:47:12 +02:00
movie_item.name = layout_title
if not movie_item.logo_url:
2026-05-08 09:54:08 +02:00
movie_item.logo_url = extract_thumbnail_from_layout(layout)
2026-05-03 20:47:12 +02:00
if not movie_item.description:
movie_item.description = (
2026-05-07 13:16:59 +02:00
layout.get("entity", {}).get("metadata", {}).get("description")
or layout.get("seo", {}).get("description")
2026-05-03 20:47:12 +02:00
)
2026-05-07 14:37:03 +02:00
return {"entries": [movie_item], "next_cursor": None, "total": 1}
2026-04-29 16:48:02 +02:00
2026-05-07 14:37:03 +02:00
# FOURTH: Fallback - look for direct episodes
direct_episodes = self._extract_direct_episodes_from_layout(layout)
if direct_episodes:
page = int(cursor) if cursor and cursor.isdigit() else 1
paginated, next_cursor = self._paginate_episodes(direct_episodes, page, page_size)
return {"entries": paginated, "next_cursor": next_cursor, "total": len(direct_episodes)}
2026-05-07 13:41:43 +02:00
2026-05-07 14:37:03 +02:00
logger.warning(f"No content found for program {program_id}")
return {"entries": [], "next_cursor": None, "total": 0}
2026-05-07 13:16:59 +02:00
2026-05-11 16:37:40 +02:00
def _extract_seasons_from_layout(self, layout: Dict, program_id: Optional[str] = None) -> List[VodCategory]:
"""Extract numbered seasons from a program layout.
All season content_ids use the ``month_{program_id}::`` prefix so they
are routed to ``_get_program_block_episodes`` (the /program/{id}/block/{block_id}
endpoint), which is the only endpoint that reliably returns episodes for
both selector-based and plain CardListM season blocks.
"""
seasons: List[VodCategory] = []
seen_content_ids: set = set()
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
continue
tealium = block.get("analytics", {}).get("tealium", {})
template_name = tealium.get("template_name")
# Path 1: Plain CardListM block with a "Staffel N" title.
# These are individual season blocks (one block per season) rather
# than a selector wrapping concurrent blocks.
if template_name == "CardListM":
block_title = None
content_title = block.get("content", {}).get("title", {})
if isinstance(content_title, dict):
block_title = content_title.get("short") or content_title.get("long")
if not block_title:
block_title = tealium.get("block_title")
if block_title and "Staffel" in block_title:
block_id = block.get("id") or block.get("blockId")
if block_id:
clean_id = self._extract_block_id_from_url(block_id)
candidate_id = (
f"month_{program_id}::{clean_id}"
if program_id
else f"season_{clean_id}"
)
if candidate_id not in seen_content_ids:
total_items = (
block.get("content", {})
.get("pagination", {})
.get("totalItems", 0)
)
logger.debug(
f"Found plain CardListM season block: {block_title!r} "
f"(raw_id={block_id}, clean_id={clean_id}, total_items={total_items})"
)
seasons.append(VodCategory(
name=block_title,
content_id=candidate_id,
provider=self._provider.provider_name,
description=None,
child_count=total_items,
))
seen_content_ids.add(candidate_id)
continue # Block fully handled; skip selector logic below.
# Path 2: Block with alternativeContent containing concurrentBlocks
# (season selector wrapping multiple season blocks).
alternative_content = block.get("alternativeContent")
if alternative_content and isinstance(alternative_content, dict):
# Skip monthly archive selectors — handled by _extract_season_selector_with_episodes.
if alternative_content.get("selectorTemplateId") == "Selector":
continue
concurrent_blocks = alternative_content.get("concurrentBlocks")
if concurrent_blocks and isinstance(concurrent_blocks, list):
for idx, cb in enumerate(concurrent_blocks):
if not cb or not isinstance(cb, dict):
continue
season_title = cb.get("title", f"Staffel {idx + 1}")
block_id = cb.get("id")
if block_id:
clean_id = self._extract_block_id_from_url(block_id)
candidate_id = (
f"month_{program_id}::{clean_id}"
if program_id
else f"season_{clean_id}"
)
if candidate_id not in seen_content_ids:
seasons.append(VodCategory(
name=season_title,
content_id=candidate_id,
provider=self._provider.provider_name,
description=None,
child_count=(
cb.get("content", {})
.get("pagination", {})
.get("totalItems", 0)
),
))
seen_content_ids.add(candidate_id)
# Path 3: Block is itself a season selector (SelectorCardListM).
# Only reached when alternativeContent is absent or non-dict, so
# there is no overlap with Path 2.
elif template_name == "SelectorCardListM":
block_id = block.get("id") or block.get("blockId")
if block_id:
clean_id = self._extract_block_id_from_url(block_id)
candidate_id = (
f"month_{program_id}::{clean_id}"
if program_id
else f"season_{clean_id}"
)
if candidate_id not in seen_content_ids:
seasons.append(VodCategory(
name=(
block.get("content", {})
.get("title", {})
.get("short", "Alle Staffeln")
),
content_id=candidate_id,
provider=self._provider.provider_name,
description=None,
child_count=(
block.get("content", {})
.get("pagination", {})
.get("totalItems", 0)
),
))
seen_content_ids.add(candidate_id)
# Sort seasons by number (Staffel 1, 2, 3...); unnamed/unnumbered go last.
def get_season_number(cat: VodCategory) -> int:
match = re.search(r"Staffel\s*(\d+)", cat.name, re.IGNORECASE)
return int(match.group(1)) if match else 999
seasons.sort(key=get_season_number)
return seasons
@staticmethod
def _handle_series_response(program_id: str, season_selector: List[VodCategory],
2026-05-07 14:37:03 +02:00
current_episodes: List[VodItem], cursor: Optional[str],
page_size: int) -> Dict[str, Any]:
"""Handle series response with monthly selector.
2026-05-07 13:16:59 +02:00
Layout returned to the caller:
1. Current-month episodes inlined as VodItems (no extra navigation step).
2. Season VodCategories sorted ascending by season number (Staffel 1, 2, …).
2026-04-29 16:48:02 +02:00
The legacy "Aktuelle Folgen" VodCategory / episodes_current_ cursor path is
intentionally removed: the episodes are already present at the top level so
there is no need for an extra folder or a separate pagination cursor.
"""
# Sort seasons ascending (Staffel 1, Staffel 2, …); unnamed entries go last.
def _season_number(cat: VodCategory) -> int:
m = re.search(r"Staffel\s*(\d+)", cat.name or "", re.IGNORECASE)
return int(m.group(1)) if m else 999
2026-05-07 13:16:59 +02:00
sorted_seasons = sorted(season_selector, key=_season_number)
# Inline current episodes at the top, then season folders below.
entries: List = list(current_episodes) + sorted_seasons
2026-04-29 16:48:02 +02:00
2026-05-07 14:37:03 +02:00
return {
"entries": entries,
"next_cursor": None,
"total": len(entries),
2026-05-07 14:37:03 +02:00
}
2026-05-07 13:16:59 +02:00
2026-05-07 14:37:03 +02:00
@staticmethod
def _handle_numbered_seasons_response(numbered_seasons: List[VodCategory],
cursor: Optional[str], page_size: int) -> Dict[str, Any]:
"""Handle series response with numbered seasons."""
start = 0
if cursor:
try:
start = int(cursor)
except ValueError:
start = 0
page = numbered_seasons[start: start + page_size]
next_cursor = str(start + page_size) if (start + page_size) < len(numbered_seasons) else None
return {"entries": page, "next_cursor": next_cursor, "total": len(numbered_seasons)}
2026-04-29 16:48:02 +02:00
2026-05-07 20:38:16 +02:00
def _extract_season_selector_with_episodes(self, layout: Dict, program_id: Optional[str] = None) -> Tuple[List[VodCategory], List[VodItem]]:
2026-05-07 13:49:28 +02:00
logger.debug("Starting _extract_season_selector_with_episodes")
2026-05-07 13:16:59 +02:00
seasons = []
current_episodes = []
2026-05-07 13:49:28 +02:00
for block_idx, block in enumerate(layout.get("blocks", [])):
logger.debug(f"Processing block {block_idx}, type: {block.get('type')}")
2026-05-07 13:16:59 +02:00
if block.get("type") != "bffPaginated":
2026-05-07 13:49:28 +02:00
logger.debug(f"Skipping block {block_idx} - not bffPaginated")
2026-05-07 13:16:59 +02:00
continue
2026-05-07 14:59:03 +02:00
alt_content = block.get("alternativeContent")
logger.debug(f"Block {block_idx} alt_content type: {type(alt_content)}")
2026-05-07 13:49:28 +02:00
2026-05-07 14:59:03 +02:00
# CRITICAL FIX: Check if alt_content is a dict before calling .get()
if alt_content and isinstance(alt_content, dict) and alt_content.get("selectorTemplateId") == "Selector":
2026-05-07 13:49:28 +02:00
logger.debug(f"Found Selector block {block_idx}")
# SAFEGUARD: concurrentBlocks might be None
concurrent_blocks = alt_content.get("concurrentBlocks")
2026-05-07 14:59:03 +02:00
logger.debug(f"concurrentBlocks type: {type(concurrent_blocks)}")
if concurrent_blocks and isinstance(concurrent_blocks, list):
for cb_idx, cb in enumerate(concurrent_blocks):
if not cb or not isinstance(cb, dict):
continue
month_title = cb.get("title")
block_id = cb.get("id")
if month_title and block_id:
total_episodes = (
cb.get("content", {})
.get("pagination", {})
.get("totalItems", 0)
)
2026-05-07 20:38:16 +02:00
# Store with program_id for proper endpoint routing
content_id = f"month_{program_id}::{block_id}" if program_id else f"month_{block_id}"
2026-05-07 14:59:03 +02:00
seasons.append(VodCategory(
name=month_title,
2026-05-07 20:38:16 +02:00
content_id=content_id,
2026-05-07 14:59:03 +02:00
provider=self._provider.provider_name,
child_count=total_episodes,
))
logger.debug(f"Added month: {month_title}")
2026-05-07 13:49:28 +02:00
# Extract current month episodes
items = block.get("content", {}).get("items", [])
logger.debug(f"Processing {len(items)} items for current episodes")
2026-05-07 13:16:59 +02:00
2026-05-07 13:49:28 +02:00
for item_idx, item in enumerate(items):
2026-05-07 14:59:03 +02:00
if not item or not isinstance(item, dict):
continue
2026-05-07 13:16:59 +02:00
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
current_episodes.append(vod_item)
2026-05-07 13:49:28 +02:00
logger.debug(f"Added episode {item_idx}: {vod_item.name}")
2026-05-07 13:16:59 +02:00
break # Found the selector block, no need to continue
2026-05-07 13:49:28 +02:00
logger.debug(f"Returning {len(seasons)} seasons, {len(current_episodes)} episodes")
2026-05-07 13:16:59 +02:00
return seasons, current_episodes
2026-05-07 14:37:03 +02:00
def _find_direct_video_in_layout(self, layout: Dict, is_series: bool = False) -> Optional[VodItem]:
"""
Find a direct playable video item in a program layout.
2026-05-07 13:57:31 +02:00
2026-05-07 14:37:03 +02:00
Args:
layout: The program layout
is_series: If True, skip Jumbotron blocks (for series)
If False, include Jumbotron blocks (for movies)
"""
2026-04-29 20:04:50 +02:00
if not layout or not isinstance(layout, dict):
return None
2026-05-07 13:57:31 +02:00
for block_idx, block in enumerate(layout.get("blocks", [])):
2026-04-29 20:04:50 +02:00
if not block or not isinstance(block, dict):
continue
2026-04-29 19:53:08 +02:00
2026-05-07 14:37:03 +02:00
# For series, skip Jumbotron blocks (they're just promos)
if is_series:
block_analytics = block.get("analytics", {})
tealium = block_analytics.get("tealium", {})
if tealium.get("template_name") == "Jumbotron":
logger.debug(f"Skipping Jumbotron block {block_idx} (is_series=True)")
continue
# Skip selector blocks
2026-05-07 13:16:59 +02:00
alt_content = block.get("alternativeContent", {})
2026-05-07 13:57:31 +02:00
if alt_content and alt_content.get("selectorTemplateId") == "Selector":
continue
2026-05-07 13:16:59 +02:00
2026-05-07 14:37:03 +02:00
# Skip season selectors
block_analytics = block.get("analytics", {})
tealium = block_analytics.get("tealium", {})
2026-05-07 13:16:59 +02:00
if tealium.get("template_name") == "SelectorCardListM":
2026-05-07 13:57:31 +02:00
continue
2026-05-07 13:16:59 +02:00
2026-05-07 14:37:03 +02:00
# Collect items from standard content location
2026-04-29 19:53:08 +02:00
content = block.get("content", {})
2026-05-07 14:37:03 +02:00
items = list(content.get("items", []) or []) if isinstance(content, dict) else []
2026-04-29 19:53:08 +02:00
2026-05-07 14:37:03 +02:00
# Also check alternativeContent for concurrent blocks
2026-04-29 20:04:50 +02:00
if isinstance(alt_content, dict):
2026-05-03 18:21:00 +02:00
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)
2026-05-07 14:37:03 +02:00
for item in items:
2026-04-29 20:04:50 +02:00
if not item or not isinstance(item, dict):
continue
2026-05-03 18:21:00 +02:00
if item.get("itemType") != "classic":
2026-04-29 14:22:31 +02:00
continue
2026-05-07 13:57:31 +02:00
item_content = item.get("itemContent")
2026-04-29 20:04:50 +02:00
if not item_content or not isinstance(item_content, dict):
2026-04-29 14:22:31 +02:00
continue
2026-05-07 13:57:31 +02:00
# Check for video in action target
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-04-29 20:04:50 +02:00
if isinstance(action, dict):
2026-05-08 09:54:08 +02:00
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
2026-04-29 16:48:02 +02:00
2026-04-29 19:53:08 +02:00
# Check direct itemContent type
2026-04-29 16:48:02 +02:00
if item_content.get("type") == "video":
clip_id = item_content.get("id")
if clip_id:
2026-05-01 21:52:04 +02:00
vod_item = VodItem.create_episode(
2026-04-29 16:48:02 +02:00
name=item_content.get("title", clip_id),
content_id=clip_id,
provider=self._provider.provider_name,
2026-05-07 13:16:59 +02:00
season_number=-1,
episode_number=-1,
2026-04-29 16:48:02 +02:00
)
vod_item.description = item_content.get("description")
2026-05-08 09:54:08 +02:00
vod_item.logo_url = extract_thumbnail(item_content)
2026-04-29 16:48:02 +02:00
return vod_item
return None
2026-05-07 13:16:59 +02:00
def _extract_direct_episodes_from_layout(self, layout: Dict) -> List[VodItem]:
2026-04-29 16:48:02 +02:00
"""
2026-05-07 13:16:59 +02:00
Extract episodes directly from layout (when there's no season selector).
2026-04-29 16:48:02 +02:00
"""
2026-05-07 13:16:59 +02:00
episodes = []
2026-04-29 16:48:02 +02:00
2026-04-29 10:37:18 +02:00
for block in layout.get("blocks", []):
if block.get("type") != "bffPaginated":
2026-03-09 17:22:42 +01:00
continue
2026-05-07 13:16:59 +02:00
# Skip blocks with selectors (they're handled separately)
if block.get("alternativeContent", {}).get("selectorTemplateId") == "Selector":
continue
2026-04-29 16:48:02 +02:00
2026-05-07 13:16:59 +02:00
# Skip numbered season selectors
2026-04-29 16:48:02 +02:00
tealium = block.get("analytics", {}).get("tealium", {})
if tealium.get("template_name") == "SelectorCardListM":
2026-05-07 13:16:59 +02:00
continue
2026-04-29 10:37:18 +02:00
2026-05-07 13:16:59 +02:00
for item in block.get("content", {}).get("items", []):
vod_item = self._extract_vod_item_from_block_item(item)
if vod_item:
episodes.append(vod_item)
return episodes
2026-04-29 10:37:18 +02:00
# ------------------------------------------------------------------
2026-05-07 13:16:59 +02:00
# Season block → episodes (legacy, kept for compatibility)
2026-04-29 10:37:18 +02:00
# ------------------------------------------------------------------
def _get_season_episodes(
2026-04-29 16:48:02 +02:00
self,
season_block_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
2026-04-29 10:37:18 +02:00
) -> Dict[str, Any]:
"""
Return VodItem per episode in a season block.
cursor is the Bedrock page number (1-based).
"""
2026-05-07 13:16:59 +02:00
return self._get_block_episodes(season_block_id, cursor, page_size, block_type="season")
2026-04-29 10:37:18 +02:00
2026-05-07 13:16:59 +02:00
# ------------------------------------------------------------------
# Utility methods
# ------------------------------------------------------------------
2026-03-09 17:22:42 +01:00
2026-05-07 13:16:59 +02:00
@staticmethod
def _extract_block_id_from_url(block_id: str) -> str:
"""
Extract the clean block ID from a full block identifier.
2026-03-09 17:22:42 +01:00
2026-05-07 13:16:59 +02:00
Examples:
- "page_69fc6d41bb2c28.53532699--c7916bb9-d111-49db-9a99-ab40ef6413da_39638"
-> "c7916bb9-d111-49db-9a99-ab40ef6413da_39638"
- "c7916bb9-d111-49db-9a99-ab40ef6413da_39638" -> unchanged
"""
if "--" in block_id:
return block_id.split("--")[-1]
return block_id
2026-03-09 17:22:42 +01:00
2026-05-07 13:16:59 +02:00
@staticmethod
def _get_current_month_name() -> Optional[str]:
"""Get the current month name in German (e.g., 'Mai 2026') for UI display."""
try:
# Use Europe/Berlin timezone for correct month rollover
now = datetime.now(ZoneInfo("Europe/Berlin"))
months = {
1: "Januar", 2: "Februar", 3: "März", 4: "April",
5: "Mai", 6: "Juni", 7: "Juli", 8: "August",
9: "September", 10: "Oktober", 11: "November", 12: "Dezember"
}
return f"{months[now.month]} {now.year}"
except Exception as e:
logger.warning(f"Could not get current month name: {e}")
return None
@staticmethod
def _paginate_episodes(episodes: List[VodItem], page: int, page_size: int) -> Tuple[List[VodItem], Optional[str]]:
"""
Paginate a list of episodes.
Returns:
tuple: (paginated_episodes, next_cursor)
"""
start_idx = (page - 1) * page_size
if start_idx >= len(episodes):
return [], None
paginated = episodes[start_idx:start_idx + page_size]
next_cursor = str(page + 1) if start_idx + page_size < len(episodes) else None
return paginated, next_cursor
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Extraction helpers
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
def _extract_vod_item_from_layout(
2026-04-29 16:48:02 +02:00
self, layout: Dict, clip_id: str
2026-04-29 10:37:18 +02:00
) -> Optional[VodItem]:
"""Build a VodItem from a full video layout (detail view)."""
assets = self._provider.extract_video_assets(layout)
if not assets:
2026-03-09 17:22:42 +01:00
return None
2026-04-29 10:37:18 +02:00
entity = layout.get("entity", {})
metadata = entity.get("metadata", {})
parent = layout.get("parent", {})
video_meta = layout.get("seo", {}).get("video", {})
vod_item = VodItem.create_episode(
name=metadata.get("title", clip_id),
content_id=clip_id,
provider=self._provider.provider_name,
2026-05-07 13:16:59 +02:00
season_number=video_meta.get("season", -1),
episode_number=video_meta.get("episode", -1),
2026-03-09 17:22:42 +01:00
)
2026-04-29 10:37:18 +02:00
vod_item.description = metadata.get("description")
2026-05-08 09:54:08 +02:00
vod_item.logo_url = extract_thumbnail_from_layout(layout)
2026-04-29 10:37:18 +02:00
vod_item.duration_seconds = video_meta.get("duration")
vod_item.genre = parent.get("seo", "")
vod_item.series_title = parent.get("name")
if any(a.get("drm") for a in assets):
vod_item.use_cdm = True
vod_item.cdm_type = "widevine"
return vod_item
2026-04-29 11:13:06 +02:00
def _extract_vod_item_from_block_item(self, item: Dict) -> Optional[VodItem]:
2026-05-07 13:49:28 +02:00
# Early validation
2026-05-10 13:19:10 +02:00
if not item or not isinstance(item, dict) or item.get("itemType") != "classic":
2026-03-09 17:22:42 +01:00
return None
2026-04-29 11:13:06 +02:00
item_content = item.get("itemContent")
2026-05-10 13:19:10 +02:00
if not item_content or not isinstance(item_content, dict):
2026-04-29 11:13:06 +02:00
return None
2026-05-10 13:19:10 +02:00
# Unwrap lock-wrapped targets
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-05-08 09:54:08 +02:00
target = unwrap_target(action.get("target", {}))
2026-04-29 10:37:18 +02:00
value_layout = target.get("value_layout", {})
2026-03-09 17:22:42 +01:00
2026-04-29 16:48:02 +02:00
clip_id = None
2026-05-01 13:55:37 +02:00
program_id = None
program_slug = None
2026-04-29 16:48:02 +02:00
if value_layout.get("type") == "video":
clip_id = value_layout.get("id")
2026-05-01 13:55:37 +02:00
parent = value_layout.get("parent", {})
program_id = parent.get("id")
program_slug = parent.get("seo")
2026-04-29 16:48:02 +02:00
elif item_content.get("type") == "video":
clip_id = item_content.get("id")
# Try alternative action locations
if not clip_id:
for action_key in ("onClickAction", "primaryAction", "secondaryAction"):
alt_action = item_content.get(action_key, {})
2026-05-01 13:55:37 +02:00
if isinstance(alt_action, dict):
2026-05-08 09:54:08 +02:00
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
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
if not clip_id:
return None
2026-03-09 17:22:42 +01:00
2026-05-10 13:19:10 +02:00
# Extract all available metadata
series_title = item_content.get("title") or ""
2026-05-10 21:37:02 +02:00
extra_title = item_content.get("extraTitle") or ""
2026-04-29 10:37:18 +02:00
highlight = item_content.get("highlight", "")
2026-05-10 13:19:10 +02:00
description = item_content.get("description", "")
2026-05-10 21:03:38 +02:00
extra_details = item_content.get("extraDetails", "")
2026-05-10 13:19:10 +02:00
2026-05-10 21:37:02 +02:00
# Episode title sourced from image.caption when available, falling back to
# extraTitle. image.caption often carries the specific episode name even
# when extraTitle is absent.
image = item_content.get("image", {}) or {}
episode_title = image.get("caption") or extra_title
2026-05-10 21:03:38 +02:00
# Parse season and episode numbers from highlight and extraDetails
2026-04-29 10:37:18 +02:00
season_number: Optional[int] = None
episode_number: Optional[int] = None
2026-05-10 13:19:10 +02:00
air_date_str: Optional[str] = None
2026-05-10 21:03:38 +02:00
year_str: Optional[str] = None
def parse_season_episode(text: str) -> None:
2026-05-10 21:37:02 +02:00
"""Extract season/episode numbers into outer scope variables.
Only writes if not already populated, so higher-priority fields
(highlight) are never overwritten by lower-priority ones (extraDetails)."""
2026-05-10 21:03:38 +02:00
nonlocal season_number, episode_number
if not text:
return
if season_number is None:
season_match = re.search(r"Staffel\s*(\d+)", text, re.IGNORECASE)
if not season_match:
# Word-boundary anchor prevents false positives like "SD" or "S-Bahn"
season_match = re.search(r"\bS(\d+)\b", text, re.IGNORECASE)
if season_match:
season_number = int(season_match.group(1))
if episode_number is None:
episode_match = re.search(r"Folge\s*(\d+)", text, re.IGNORECASE)
if not episode_match:
episode_match = re.search(r"\bE(\d+)\b", text, re.IGNORECASE)
if not episode_match:
episode_match = re.search(r"\bTeil\s*(\d+)\b", text, re.IGNORECASE)
if episode_match:
episode_number = int(episode_match.group(1))
# Parse highlight first (highest priority)
2026-04-29 11:13:06 +02:00
if highlight:
2026-05-10 21:03:38 +02:00
parse_season_episode(highlight)
2026-05-10 13:19:10 +02:00
date_match = re.search(r"(\d{2})\.(\d{2})\.(\d{2,4})", highlight)
if date_match:
day, month, year = date_match.groups()
if len(year) == 2:
year = f"20{year}"
air_date_str = f"{day}.{month}.{year}"
2026-05-10 21:03:38 +02:00
# Fall back to extraDetails for season/episode if highlight didn't provide them
if extra_details and (season_number is None or episode_number is None):
parse_season_episode(extra_details)
# Extract broadcast year from extraDetails (e.g. "1991 • 22 Min.").
# Anchored to plausible broadcast years to avoid matching clip IDs or
# duration fragments.
if extra_details and not year_str:
year_match = re.search(r"\b(19|20)\d{2}\b", extra_details)
if year_match:
year_str = year_match.group(0)
2026-05-10 13:19:10 +02:00
# Build the display name based on content type
display_name = None
2026-05-10 21:37:02 +02:00
# Case 1: Has episode title (from image.caption or extraTitle)
2026-05-10 13:19:10 +02:00
if episode_title:
# Season + episode numbers are stored in structured fields; don't repeat
# them in the display name. Only append the air date for disambiguation
# when there is no season number (e.g. GZSZ daily episodes: E8521).
2026-05-10 13:19:10 +02:00
if season_number and episode_number:
# Full S/E info lives in structured fields → clean title only
display_name = episode_title
2026-05-10 13:19:10 +02:00
elif episode_number:
# No season, but episode number: add it together with air date
# so items stay distinguishable (e.g. "Klarer Cut (E8522 - 30.04.2026)")
if air_date_str:
display_name = f"{episode_title} (E{episode_number} - {air_date_str})"
else:
display_name = f"{episode_title} (E{episode_number})"
2026-05-10 21:37:02 +02:00
elif air_date_str:
display_name = f"{episode_title} - {air_date_str}"
2026-05-10 13:19:10 +02:00
else:
display_name = episode_title
2026-05-10 21:03:38 +02:00
# Append year only for standalone/movie content — for a named series
# every episode would share the same year, adding no disambiguation value
if year_str and not series_title:
display_name = f"{display_name} ({year_str})"
2026-05-10 13:19:10 +02:00
# Case 2: No episode title, but parsed episode/season numbers
elif highlight and (season_number or episode_number):
if season_number and episode_number:
display_name = f"Staffel {season_number} • Folge {episode_number}"
elif episode_number:
display_name = f"Folge {episode_number}"
else:
parts = highlight.split("•")
display_name = " • ".join(parts[1:]).strip() if len(parts) >= 2 else highlight
if air_date_str and air_date_str not in display_name:
display_name = f"{display_name} - {air_date_str}"
# Case 3: Has date only (daily shows like news, talk shows)
elif air_date_str:
if series_title:
display_name = f"{series_title} - {air_date_str}"
elif highlight:
series_part = highlight.split("•")[0].strip()
display_name = f"{series_part} - {air_date_str}"
else:
display_name = air_date_str
2026-05-10 21:03:38 +02:00
# Case 4: Short description (movies where description is the plot synopsis)
2026-05-10 13:19:10 +02:00
elif description and len(description) < 100 and "•" not in description:
display_name = description
2026-05-10 21:03:38 +02:00
# Case 5: Series title with movie vs. episodic disambiguation
2026-05-10 13:19:10 +02:00
elif series_title:
if season_number or episode_number:
# Episodic content without a clean title: slice the highlight
if highlight and "•" in highlight:
parts = highlight.split("•")
display_name = " • ".join(parts[1:]).strip()
else:
display_name = highlight or series_title
2026-04-29 11:32:44 +02:00
else:
2026-05-10 13:19:10 +02:00
# No episode indicators — treat as a movie or standalone clip
display_name = series_title
else:
# Ultimate fallback
display_name = highlight or f"Unbekanntes Video ({clip_id})"
2026-04-29 10:37:18 +02:00
2026-05-10 21:03:38 +02:00
# Normalise whitespace and strip stray punctuation introduced by concatenation
if display_name:
display_name = re.sub(r'\s+', ' ', display_name).strip(' -–—•\t\r\n')
2026-05-10 13:19:10 +02:00
# Create the VodItem
2026-04-29 10:37:18 +02:00
vod_item = VodItem.create_episode(
2026-05-10 13:19:10 +02:00
name=display_name,
2026-05-03 18:21:00 +02:00
content_id=clip_id,
2026-04-29 10:37:18 +02:00
provider=self._provider.provider_name,
2026-04-29 16:48:02 +02:00
season_number=season_number if season_number is not None else -1,
2026-04-29 11:32:44 +02:00
episode_number=episode_number if episode_number is not None else -1,
2026-03-09 17:22:42 +01:00
)
2026-05-10 13:19:10 +02:00
# Store additional metadata
vod_item.series_title = series_title or None
vod_item.episode_title = episode_title or None
2026-05-10 21:03:38 +02:00
vod_item.description = description or highlight or extra_details
2026-05-08 09:54:08 +02:00
vod_item.logo_url = extract_thumbnail(item_content)
2026-04-29 10:37:18 +02:00
vod_item.duration_seconds = self._extract_duration(item_content)
vod_item.progress = item_content.get("progress", 0)
2026-05-10 13:19:10 +02:00
if air_date_str:
vod_item.air_date = air_date_str
2026-05-10 21:03:38 +02:00
if year_str:
vod_item.year = int(year_str)
2026-05-03 18:21:00 +02:00
# Store program context for manifest fetching
2026-05-02 18:32:49 +02:00
if program_id or program_slug:
vod_item.manifest_script = json.dumps({
"program_id": program_id,
"program_slug": program_slug,
"clip_id": clip_id
})
2026-05-01 13:55:37 +02:00
2026-04-29 10:37:18 +02:00
return vod_item
2026-04-29 11:13:06 +02:00
def _extract_vod_category_from_block_item(self, item: Dict) -> Optional[VodCategory]:
2026-04-29 10:37:18 +02:00
"""Build a VodCategory from a list/block item (folder or program row)."""
2026-04-29 11:13:06 +02:00
if not item or item.get("itemType") != "classic":
2026-04-29 10:37:18 +02:00
return None
2026-03-09 17:22:42 +01:00
2026-04-29 11:13:06 +02:00
item_content = item.get("itemContent")
2026-06-04 13:12:24 +02:00
if not item_content or not isinstance(item_content, dict):
return None
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-06-04 13:12:24 +02:00
# FIX: Check if action is None or not a dict before using .get()
if not action or not isinstance(action, dict):
2026-04-29 11:13:06 +02:00
return None
2026-05-08 09:54:08 +02:00
target = unwrap_target(action.get("target", {}))
2026-04-29 11:13:06 +02:00
value_layout = target.get("value_layout", {})
2026-03-09 17:22:42 +01:00
2026-04-29 10:37:18 +02:00
layout_type = value_layout.get("type")
if layout_type not in ("folder", "program"):
return None
content_id = value_layout.get("id")
if not content_id:
2026-03-09 17:22:42 +01:00
return None
2026-05-08 12:34:04 +02:00
# Store the raw ID for folder name mapping (before adding prefix)
raw_id = content_id
# Add prefix for content_id
2026-05-01 12:16:49 +02:00
if layout_type == "folder":
2026-05-03 18:21:00 +02:00
content_id = f"folder_{content_id}"
2026-05-01 12:16:49 +02:00
elif layout_type == "program":
2026-05-03 18:21:00 +02:00
content_id = f"program_{content_id}"
2026-05-01 12:16:49 +02:00
2026-04-29 22:38:23 +02:00
seo_slug = value_layout.get("seo") or ""
2026-05-08 12:34:04 +02:00
# Extract name with priority order
2026-04-29 11:32:44 +02:00
name = item_content.get("title")
2026-04-29 12:33:55 +02:00
if not name and layout_type == "folder":
2026-05-08 12:34:04 +02:00
# FIX: Use raw_id (without "folder_" prefix) to check FOLDER_NAMES
if raw_id in FOLDER_NAMES:
name = FOLDER_NAMES[raw_id]
2026-04-29 12:33:55 +02:00
else:
2026-05-08 12:34:04 +02:00
# Try image caption as fallback (API provides good names here)
image = item_content.get("image", {})
if image.get("caption"):
name = image.get("caption")
else:
# Fallback to SEO slug
name = seo_slug.replace("-", " ").title() if seo_slug else f"Kategorie {raw_id}"
2026-04-29 12:33:55 +02:00
elif not name:
2026-05-08 12:34:04 +02:00
# For programs or when title is missing
2026-04-29 12:33:55 +02:00
name = item_content.get("extraTitle") or item_content.get("highlight")
2026-05-03 18:21:00 +02:00
if name and "•" in str(name):
name = name.split("•")[0].strip()
2026-04-29 11:32:44 +02:00
if not name:
name = f"Unbekannt {layout_type}"
2026-04-29 10:37:18 +02:00
return VodCategory(
2026-04-29 11:32:44 +02:00
name=name,
2026-05-03 18:21:00 +02:00
content_id=content_id,
2026-04-29 10:37:18 +02:00
provider=self._provider.provider_name,
2026-05-08 09:54:08 +02:00
logo_url=extract_thumbnail(item_content),
2026-04-29 12:33:55 +02:00
description=item_content.get("description") or item_content.get("highlight"),
2026-03-09 17:22:42 +01:00
)
2026-04-29 10:37:18 +02:00
@staticmethod
def _item_has_video_assets(item: Dict) -> bool:
"""True when a block item directly carries playable video assets."""
return bool(
item.get("itemContent", {})
2026-04-29 16:48:02 +02:00
.get("video", {})
.get("assets")
2026-03-09 17:22:42 +01:00
)
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Thumbnail / duration helpers (pure, no I/O)
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-05-08 09:54:08 +02:00
# 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.
@classmethod
def _build_image_url(cls, image_id: str) -> str:
return build_image_url(image_id)
2026-05-03 20:47:12 +02:00
@classmethod
def _extract_thumbnail(cls, item_content: Dict) -> Optional[str]:
2026-05-08 09:54:08 +02:00
return extract_thumbnail(item_content)
2026-04-29 10:37:18 +02:00
2026-05-03 20:47:12 +02:00
@classmethod
def _extract_thumbnail_from_layout(cls, layout: Dict) -> Optional[str]:
2026-05-08 09:54:08 +02:00
return extract_thumbnail_from_layout(layout)
2026-04-29 10:37:18 +02:00
@staticmethod
def _extract_duration(item_content: Dict) -> Optional[int]:
"""Parse HH:MM:SS from progressBar.endTitle → seconds."""
end_title = item_content.get("progressBar", {}).get("endTitle", "")
if end_title:
parts = end_title.split(":")
if len(parts) == 3:
try:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
except ValueError:
pass
return None
2026-03-09 17:22:42 +01:00
2026-04-29 12:33:55 +02:00
@staticmethod
def _is_future_event(item_content: Dict) -> bool:
"""Check if item is a future event (should be excluded from VOD)."""
highlight = item_content.get("highlight", "")
if not highlight:
return False
2026-05-08 09:54:08 +02:00
event_date = parse_german_datetime(highlight)
return event_date is not None and event_date > datetime.now()
2026-04-29 12:33:55 +02:00
@staticmethod
def _is_event_item(item_content: Dict) -> bool:
"""Check if item is an event (vs regular VOD content)."""
highlight = item_content.get("highlight", "")
if not highlight:
return False
has_datetime = bool(re.search(r"\d{2}\.\d{2}\.\d{2}", highlight))
2026-06-04 14:25:11 +02:00
action = item_content.get("action") or {}
2026-04-29 12:33:55 +02:00
target = action.get("target", {})
value_layout = target.get("value_layout", {})
# Events are typically folders, not programs or videos
2026-04-29 16:48:02 +02:00
return has_datetime and value_layout.get("type") == "folder"
2026-04-29 12:33:55 +02:00
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
# Cache management
2026-03-09 17:22:42 +01:00
# ------------------------------------------------------------------
2026-04-29 10:37:18 +02:00
def invalidate_cache(self, content_id: Optional[str] = None) -> None:
2026-03-09 17:22:42 +01:00
"""
2026-04-29 10:37:18 +02:00
Invalidate cached layouts for the given node, or everything.
content_id uses the same prefix conventions as get_vod_category.
2026-03-09 17:22:42 +01:00
"""
2026-04-29 10:37:18 +02:00
if not content_id:
self._provider.invalidate_layout_cache()
return
2026-05-01 21:52:04 +02:00
if content_id.startswith("program_"):
program_id = content_id[len("program_"):]
2026-04-29 10:37:18 +02:00
cache_key = (
f"program:{program_id}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}"
f":{RTLPlusDefaults.DEFAULT_NB_PAGES}"
)
self._provider.invalidate_layout_cache(cache_key)
2026-05-07 13:16:59 +02:00
# Also invalidate current episodes cache
self._provider.invalidate_layout_cache(f"program:{program_id}:current_episodes")
2026-04-29 10:37:18 +02:00
2026-05-01 21:52:04 +02:00
elif content_id.startswith("season_"):
season_id = content_id[len("season_"):].split("?")[0]
2026-04-29 10:37:18 +02:00
cache_key = (
f"block:{season_id}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES}"
2026-03-09 17:22:42 +01:00
)
2026-04-29 10:37:18 +02:00
self._provider.invalidate_layout_cache(cache_key)
2026-05-07 13:16:59 +02:00
elif content_id.startswith("month_"):
2026-05-07 20:38:16 +02:00
rest = content_id[6:]
block_id = rest.split("::", 1)[-1] if "::" in rest else rest.split("?")[0]
2026-05-07 13:16:59 +02:00
cache_key = (
2026-05-07 20:38:16 +02:00
f"block:{block_id}"
2026-05-07 13:16:59 +02:00
f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_NB_PAGES}"
)
self._provider.invalidate_layout_cache(cache_key)
2026-05-01 21:52:04 +02:00
elif content_id.startswith("folder_"):
folder_id = content_id[len("folder_"):]
2026-04-29 10:37:18 +02:00
cache_key = (
f"folder:{folder_id}"
f":{RTLPlusDefaults.DEFAULT_BLOCK_PAGE}"
f":{RTLPlusDefaults.DEFAULT_NB_PAGES}"
)
self._provider.invalidate_layout_cache(cache_key)
else:
# Unknown prefix — wipe everything rather than silently doing nothing
self._provider.invalidate_layout_cache()