Backend add favorites

This commit is contained in:
Nirvana
2026-05-15 17:24:39 +02:00
parent 27635f8432
commit c55daedca6
3 changed files with 456 additions and 1 deletions
@@ -579,4 +579,9 @@ class RTLPlusConfig:
client_version=self.client_version,
user_agent=self.user_agent,
location=location,
)
)
@property
def users_base_url(self) -> str:
"""Base URL for user-related API endpoints (bookmarks, etc.)"""
return "https://users.rtlde.bedrock.tech/v4/rtlde/m6group_web"
@@ -0,0 +1,415 @@
# streaming_providers/providers/rtlplus/favorite_manager.py
"""
RTL+ Favorite Manager
Handles program bookmarks (favorites) using the Bedrock layout API and
bookmark endpoints. Follows the same pattern as vod_manager, channel_manager,
and event_manager.
"""
from typing import Dict, List, Optional
from ...base.models.favorite import Favorite, FavoriteType
from ...base.utils.logger import logger
from .layout_helpers import unwrap_target, extract_thumbnail
# Block/item type identifiers from the RTL+ Bedrock layout API.
# Update these constants if the API schema changes.
_BLOCK_TYPE_PAGINATED = "bffPaginated"
_TEMPLATE_NAME_BOOKMARKS = "PortraitList"
_ITEM_TYPE_CLASSIC = "classic"
_CONTENT_TYPE_PROGRAM = "program"
# Maximum number of pages to fetch when paginating bookmarks.
# Each page contains up to 8 items; 10 pages = up to 80 bookmarks.
_MAX_BOOKMARK_PAGES = 10
class RTLPlusFavoriteManager:
"""
Manages favorite/bookmarked programs for RTL+.
Favorites are program-level bookmarks (not clip-level). The user can
bookmark entire shows/series, which appear in the "Meine gemerkten Inhalte"
section of the RTL+ frontend.
API Endpoints:
- GET: /frontspace/bookmarks/layout (via Bedrock layout system)
- PUT: https://users.rtlde.bedrock.tech/v4/rtlde/m6group_web/bookmark
"""
def __init__(self, provider):
self._provider = provider
# --------------------------------------------------------------------------
# Convenience accessors
# --------------------------------------------------------------------------
@property
def cfg(self):
return self._provider.rtl_config
@property
def http(self):
return self._provider.http_manager
@property
def auth(self):
return self._provider.authenticator
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def get_favorites(self, **kwargs) -> List[Favorite]:
"""
Get all bookmarked programs for the authenticated user.
Paginates through all available pages (up to _MAX_BOOKMARK_PAGES)
to ensure the full bookmark list is returned.
Returns:
List of Favorite objects (programs only, not clips)
"""
if not self.auth.has_user_credentials():
logger.warning("RTL+ get_favorites: No user credentials, returning empty list")
return []
oauth_token = self._provider.get_user_bearer_token()
if not oauth_token:
logger.warning("RTL+ get_favorites: No valid user token")
return []
try:
favorites = self._fetch_all_favorites()
logger.info(f"RTL+ get_favorites: Found {len(favorites)} bookmarked programs")
return favorites
except Exception as e:
logger.error(f"RTL+ get_favorites failed: {e}", exc_info=True)
return []
def add_favorite(
self,
content_id: str,
favorite_type: FavoriteType,
title: Optional[str] = None,
**kwargs,
) -> Optional[Favorite]:
"""
Add a program to user's bookmarks.
Args:
content_id: Numeric program ID (e.g., "10438")
favorite_type: Should be FavoriteType.PROGRAM
title: Program title (optional, for caching)
Returns:
Favorite object if successful, None otherwise
"""
if not self.auth.has_user_credentials():
logger.error("RTL+ add_favorite: No user credentials")
return None
if favorite_type != FavoriteType.PROGRAM:
logger.warning(
f"RTL+ add_favorite: Only PROGRAM type supported, got {favorite_type}"
)
return None
if not content_id.isdigit():
logger.error(
f"RTL+ add_favorite: content_id must be numeric program ID, got '{content_id}'"
)
return None
success = self._call_bookmark_api(content_id, subscribed=True)
if not success:
return None
# Invalidate cache so subsequent get_favorites() calls reflect the change.
self.invalidate_cache()
return Favorite.create(
provider=self._provider.provider_name,
content_id=content_id,
favorite_type=FavoriteType.PROGRAM,
title=title,
)
def remove_favorite(self, content_id: str, **kwargs) -> None:
"""
Remove a program from user's bookmarks.
Args:
content_id: Numeric program ID (e.g., "10438")
Raises:
KeyError: If content_id is not a valid numeric program ID
RuntimeError: If the API call fails or user has no credentials
"""
if not self.auth.has_user_credentials():
raise RuntimeError("RTL+ remove_favorite: No user credentials")
if not content_id.isdigit():
raise KeyError(
f"Invalid content_id format: '{content_id}' (expected numeric program ID)"
)
# Attempt the removal directly; _call_bookmark_api will return False
# if the program wasn't bookmarked or the request fails.
success = self._call_bookmark_api(content_id, subscribed=False)
if not success:
raise RuntimeError(f"Failed to remove bookmark for program {content_id}")
# Invalidate cache so subsequent get_favorites() calls reflect the change.
self.invalidate_cache()
def is_favorited(self, content_id: str, favorites: Optional[List[Favorite]] = None) -> bool:
"""
Check if a specific program is bookmarked.
Args:
content_id: Numeric program ID
favorites: Optional pre-fetched favorites list. Pass this when
checking multiple items to avoid redundant API calls.
Returns:
True if bookmarked, False otherwise
"""
if favorites is None:
favorites = self.get_favorites()
return any(f.content_id == content_id for f in favorites)
# --------------------------------------------------------------------------
# Private helper methods
# --------------------------------------------------------------------------
def _fetch_all_favorites(self) -> List[Favorite]:
"""
Fetch all bookmarked programs by paginating through layout pages.
Stops early when a page returns no items or when _MAX_BOOKMARK_PAGES
is reached.
Returns:
Combined list of Favorite objects from all pages
"""
all_favorites: List[Favorite] = []
for page in range(1, _MAX_BOOKMARK_PAGES + 1):
layout = self._fetch_bookmarks_layout(block_page=page)
if not layout:
if page == 1:
logger.warning("RTL+ _fetch_all_favorites: Failed to fetch page 1")
break
page_favorites = self._extract_favorites_from_layout(layout)
if not page_favorites:
# No items on this page — we've exhausted the list.
break
all_favorites.extend(page_favorites)
logger.debug(
f"RTL+ _fetch_all_favorites: page {page} returned {len(page_favorites)} items "
f"(total so far: {len(all_favorites)})"
)
return all_favorites
def _fetch_bookmarks_layout(self, block_page: int = 1) -> Optional[Dict]:
"""
Fetch a single page of the bookmarks layout from the Bedrock API.
Args:
block_page: 1-based page index to fetch
Returns:
Layout dict or None if the request failed
"""
oauth_token = self._provider.get_user_bearer_token()
if not oauth_token:
logger.error("RTL+ _fetch_bookmarks_layout: No OAuth token")
return None
try:
bedrock_token = self.auth.get_bedrock_token()
except Exception as e:
logger.error(f"RTL+ _fetch_bookmarks_layout: Failed to get Bedrock token: {e}")
return None
if not bedrock_token:
logger.error("RTL+ _fetch_bookmarks_layout: No Bedrock token")
return None
url = f"{self.cfg.bedrock_layout_base}/frontspace/bookmarks/layout"
location = self.cfg.base_website
headers = self.cfg.get_layout_headers(oauth_token, bedrock_token, location)
params = {
"blockPage": block_page,
"nbPages": 1, # Fetch one page at a time for correct pagination.
}
try:
response = self.http.get(url, headers=headers, params=params, operation="api")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"RTL+ _fetch_bookmarks_layout: Request failed (page {block_page}): {e}")
return None
def _extract_favorites_from_layout(self, layout: Dict) -> List[Favorite]:
"""
Extract Favorite objects from a bookmarks layout page.
Looks for blocks with:
- type = "bffPaginated"
- analytics.tealium.template_name = "PortraitList"
- items with itemType = "classic" and action.target.value_layout.type = "program"
"""
favorites = []
blocks = layout.get("blocks", [])
for block in blocks:
if block.get("type") != _BLOCK_TYPE_PAGINATED:
continue
tealium = block.get("analytics", {}).get("tealium", {})
if tealium.get("template_name") != _TEMPLATE_NAME_BOOKMARKS:
continue
items = block.get("content", {}).get("items", [])
for item in items:
if item.get("itemType") != _ITEM_TYPE_CLASSIC:
continue
favorite = self._extract_favorite_from_item(item)
if favorite:
favorites.append(favorite)
return favorites
def _extract_favorite_from_item(self, item: Dict) -> Optional[Favorite]:
"""
Extract a single Favorite from a layout item.
Expected structure (from RTL+ API):
{
"itemContent": {
"title": "Let's Dance",
"description": "...",
"image": {...},
"action": {
"target": {
"value_layout": {
"type": "program",
"id": "10438",
"seo": "lets-dance"
}
}
},
"bookmark": {"id": "10438", "state": true, "type": "program"}
}
}
"""
item_content = item.get("itemContent", {})
if not item_content:
return None
action = item_content.get("action", {})
target = unwrap_target(action.get("target", {}))
value_layout = target.get("value_layout", {})
if value_layout.get("type") != _CONTENT_TYPE_PROGRAM:
return None
program_id = value_layout.get("id")
if not program_id:
return None
title = item_content.get("title", "")
description = item_content.get("description", "")
thumbnail_url = extract_thumbnail(item_content)
bookmark = item_content.get("bookmark", {})
if not bookmark.get("state", False):
logger.debug(f"Item {program_id} has bookmark.state=false, skipping")
return None
favorite = Favorite.create(
provider=self._provider.provider_name,
content_id=program_id,
favorite_type=FavoriteType.PROGRAM,
title=title,
)
favorite.description = description
favorite.thumbnail_url = thumbnail_url
return favorite
def _call_bookmark_api(self, program_id: str, subscribed: bool) -> bool:
"""
Call the Bedrock bookmark API to add/remove a favorite.
Args:
program_id: Numeric program ID (e.g., "10438")
subscribed: True to add, False to remove
Returns:
True if successful, False otherwise
"""
oauth_token = self._provider.get_user_bearer_token()
if not oauth_token:
logger.error("RTL+ _call_bookmark_api: No OAuth token")
return False
url = f"{self.cfg.users_base_url}/bookmark"
payload = {
"entityId": program_id,
"entityType": _CONTENT_TYPE_PROGRAM,
"subscribed": subscribed,
}
headers = {
"Authorization": f"Bearer {oauth_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
try:
response = self.http.put(
url,
headers=headers,
json=payload,
operation="api",
)
response.raise_for_status()
result = response.json()
logger.debug(f"RTL+ bookmark API subscribed={subscribed}: {result}")
if "subscribed" not in result:
logger.warning(
f"RTL+ _call_bookmark_api: Response missing 'subscribed' field "
f"for program {program_id}. Full response: {result}"
)
return False
return result["subscribed"] == subscribed
except Exception as e:
logger.error(f"RTL+ _call_bookmark_api failed for program {program_id}: {e}")
return False
def invalidate_cache(self) -> None:
"""
Invalidate the bookmarks layout cache.
Called automatically after add_favorite() and remove_favorite() to
ensure subsequent get_favorites() calls return fresh data.
Can also be called manually if needed.
"""
cache_key = "bookmarks:layout:*"
self._provider.invalidate_layout_cache(cache_key)
@@ -9,12 +9,14 @@ from ...base.models import DRMConfig, StreamingChannel, Event
from ...base.models.proxy_models import ProxyConfig
from ...base.provider import StreamingProvider
from ...base.utils import logger
from ...base.models.favorite import FavoriteType, Favorite
from .auth import RTLPlusAuthenticator
from .constants import RTLPlusConfig, RTLPlusDefaults
from .layout_helpers import unwrap_target
from .vod_manager import RTLPlusVodManager
from .channel_manager import RTLPlusChannelManager
from .event_manager import RTLPlusEventManager
from .favorite_manager import RTLPlusFavoriteManager
_MANIFEST_CACHE_TTL = 86400 # 1 day in seconds for VOD/events
@@ -77,6 +79,7 @@ class RTLPlusProvider(StreamingProvider):
self._vod_manager = RTLPlusVodManager(self)
self.channel_manager = RTLPlusChannelManager(self)
self.event_manager = RTLPlusEventManager(self)
self._favorite_manager = RTLPlusFavoriteManager(self)
# Manifest cache for VOD/events
self._manifest_cache: Dict[str, tuple] = {}
@@ -110,6 +113,38 @@ class RTLPlusProvider(StreamingProvider):
"""RTL+ has a browsable VOD catalogue."""
return True
@property
def implements_favorites(self) -> bool:
"""RTL+ supports program bookmarks."""
return True
def get_favorites(self, **kwargs) -> List[Favorite]:
"""Get all bookmarked programs for the authenticated user."""
return self._favorite_manager.get_favorites(**kwargs)
def add_favorite(
self,
content_id: str,
favorite_type: FavoriteType,
title: Optional[str] = None,
**kwargs,
) -> Optional[Favorite]:
"""Add a program to user's bookmarks."""
return self._favorite_manager.add_favorite(
content_id=content_id,
favorite_type=favorite_type,
title=title,
**kwargs,
)
def remove_favorite(self, content_id: str, **kwargs) -> None:
"""Remove a program from user's bookmarks."""
self._favorite_manager.remove_favorite(content_id=content_id, **kwargs)
def is_favorited(self, content_id: str) -> bool:
"""Check if a specific program is bookmarked."""
return self._favorite_manager.is_favorited(content_id)
# --------------------------------------------------------------------------
# Common Layout Methods
# --------------------------------------------------------------------------