mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 07:02:27 +02:00
Backend add favorites
This commit is contained in:
@@ -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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user