From c55daedca6088e6fb24e2f4f1017a047264dacc8 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 15 May 2026 17:24:39 +0200 Subject: [PATCH] Backend add favorites --- .../providers/rtlplus/constants.py | 7 +- .../providers/rtlplus/favorite_manager.py | 415 ++++++++++++++++++ .../providers/rtlplus/provider.py | 35 ++ 3 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 lib/streaming_providers/providers/rtlplus/favorite_manager.py diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index ce9b947..9ac493f 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -579,4 +579,9 @@ class RTLPlusConfig: client_version=self.client_version, user_agent=self.user_agent, location=location, - ) \ No newline at end of file + ) + + @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" \ No newline at end of file diff --git a/lib/streaming_providers/providers/rtlplus/favorite_manager.py b/lib/streaming_providers/providers/rtlplus/favorite_manager.py new file mode 100644 index 0000000..13f6ecf --- /dev/null +++ b/lib/streaming_providers/providers/rtlplus/favorite_manager.py @@ -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) \ No newline at end of file diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index a1d4413..7ebed9d 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -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 # --------------------------------------------------------------------------