From fd7aab531903b4887ef17133b40811c5f4740dec Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 15 May 2026 16:17:32 +0200 Subject: [PATCH] Backend add favorites --- .../base/favorite_operations.py | 108 +++++ lib/streaming_providers/base/manager.py | 47 ++ .../base/models/favorite.py | 72 +++ lib/streaming_providers/base/provider.py | 63 +++ routes/favorites.py | 441 ++++++++++++++++++ service.py | 2 + 6 files changed, 733 insertions(+) create mode 100644 lib/streaming_providers/base/favorite_operations.py create mode 100644 lib/streaming_providers/base/models/favorite.py create mode 100644 routes/favorites.py diff --git a/lib/streaming_providers/base/favorite_operations.py b/lib/streaming_providers/base/favorite_operations.py new file mode 100644 index 0000000..dc32091 --- /dev/null +++ b/lib/streaming_providers/base/favorite_operations.py @@ -0,0 +1,108 @@ +# streaming_providers/base/favorite_operations.py +""" +Favorite-related operations separated from core registry. +Mirrors the structure of BookmarkOperations. +""" + +from typing import Dict, List, Optional + +from .models.favorite import Favorite, FavoriteType +from .utils.logger import logger + + +class FavoriteOperations: + """Handles all favorite-related operations.""" + + def __init__(self, registry): + self.registry = registry + logger.debug("FavoriteOperations: Initialized") + + def get_favorites( + self, + provider_name: str, + favorite_type: Optional[FavoriteType] = None, + ) -> List[Favorite]: + """Get favorites from a specific provider.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.implements_favorites: + return [] + + favorites = provider.get_favorites() + + if favorite_type: + favorites = [f for f in favorites if f.favorite_type == favorite_type] + + return favorites + + def add_favorite( + self, + provider_name: str, + content_id: str, + favorite_type: FavoriteType, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Favorite]: + """Add a favorite.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.implements_favorites: + return None + + return provider.add_favorite( + content_id=content_id, + favorite_type=favorite_type, + title=title, + **kwargs, + ) + + def remove_favorite( + self, provider_name: str, content_id: str + ) -> bool: + """Remove a favorite.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.implements_favorites: + return False + + try: + provider.remove_favorite(content_id=content_id) + return True + except KeyError: + return False + + def get_all_favorites( + self, + favorite_type: Optional[FavoriteType] = None, + ) -> Dict[str, List[Favorite]]: + """Get favorites from all enabled providers.""" + enabled = self.registry.get_enabled_providers() + result = {} + errors = {} + + for name in enabled: + try: + favorites = self.get_favorites(name, favorite_type=favorite_type) + result[name] = favorites + except Exception as e: + logger.error(f"Failed to get favorites from '{name}': {e}") + result[name] = [] + errors[name] = str(e) + + if errors: + result["_errors"] = errors + + return result + + def is_favorited( + self, provider_name: str, content_id: str + ) -> bool: + """Check if content is favorited.""" + favorites = self.get_favorites(provider_name) + return any(f.content_id == content_id for f in favorites) \ No newline at end of file diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index 45d8809..1d266f8 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -15,8 +15,10 @@ from .vod_operations import VodOperations from .recording_operations import RecordingOperations from .timer_operations import TimerOperations from .bookmark_operations import BookmarkOperations +from .favorite_operations import FavoriteOperations from .models import StreamingChannel from .models.bookmark import Bookmark, ContentType +from .models.favorite import FavoriteType, Favorite from .provider_registry import ProviderRegistry from .subscription_operations import SubscriptionOperations from .utils.logger import logger @@ -43,6 +45,7 @@ class ProviderManager: self.recording_ops = RecordingOperations(self.registry) self.timer_ops = TimerOperations(self.registry) self.bookmark_ops = BookmarkOperations(self.registry) + self.favorite_ops = FavoriteOperations(self.registry) # Backward compatibility - expose managers directly self.drm_plugin_manager = self.drm_ops.drm_plugin_manager @@ -649,6 +652,50 @@ class ProviderManager: """ return self.bookmark_ops.validate_bookmarks(provider_name, auto_fix=auto_fix) + # ========================================================================== + # Favorite OPERATIONS (delegate to FavoriteOperations) + # ========================================================================== + + def get_favorites( + self, + provider_name: str, + favorite_type: Optional[FavoriteType] = None, + ) -> List[Favorite]: + """Get favorites from a specific provider.""" + return self.favorite_ops.get_favorites(provider_name, favorite_type) + + def add_favorite( + self, + provider_name: str, + content_id: str, + favorite_type: FavoriteType, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Favorite]: + """Add a favorite.""" + return self.favorite_ops.add_favorite( + provider_name, content_id, favorite_type, title, **kwargs + ) + + def remove_favorite( + self, provider_name: str, content_id: str + ) -> bool: + """Remove a favorite.""" + return self.favorite_ops.remove_favorite(provider_name, content_id) + + def get_all_favorites( + self, + favorite_type: Optional[FavoriteType] = None, + ) -> Dict[str, List[Favorite]]: + """Get favorites from all enabled providers.""" + return self.favorite_ops.get_all_favorites(favorite_type) + + def is_favorited( + self, provider_name: str, content_id: str + ) -> bool: + """Check if content is favorited.""" + return self.favorite_ops.is_favorited(provider_name, content_id) + # ========================================================================== # SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations) # ========================================================================== diff --git a/lib/streaming_providers/base/models/favorite.py b/lib/streaming_providers/base/models/favorite.py new file mode 100644 index 0000000..8b97e2d --- /dev/null +++ b/lib/streaming_providers/base/models/favorite.py @@ -0,0 +1,72 @@ +# streaming_providers/base/models/favorite.py + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Dict, Optional + + +class FavoriteType(str, Enum): + """Type of content that can be favorited.""" + PROGRAM = "PROGRAM" # TV show, series, or movie + CLIP = "CLIP" # Individual episode or video + LIVE = "LIVE" # Live channel + EVENT = "EVENT" # One-time event + + +@dataclass +class Favorite: + """ + A user-saved content reference for "Watch Later" or "My Bookmarks". + + Unlike Bookmark (which tracks playback position), Favorite is just + a pointer to content the user wants to remember. No progress tracking. + """ + + favorite_id: str # Composite "provider:content_id" + provider: str + content_id: str + favorite_type: FavoriteType + + # When it was saved + created_at: datetime = field(default_factory=datetime.now) + + # Optional cached metadata for UI (reduces API calls) + title: Optional[str] = None + thumbnail_url: Optional[str] = None + + # Series context (if applicable) + series_title: Optional[str] = None + + @classmethod + def create( + cls, + provider: str, + content_id: str, + favorite_type: FavoriteType, + title: Optional[str] = None, + **kwargs, + ) -> "Favorite": + """Create a new favorite.""" + return cls( + favorite_id=f"{provider}:{content_id}", + provider=provider, + content_id=content_id, + favorite_type=favorite_type, + title=title, + created_at=datetime.now(), + **kwargs, + ) + + def to_dict(self) -> Dict: + """Convert to API-friendly dictionary.""" + return { + "FavoriteId": self.favorite_id, + "Provider": self.provider, + "ContentId": self.content_id, + "FavoriteType": self.favorite_type.value, + "CreatedAt": self.created_at.isoformat(), + "Title": self.title, + "ThumbnailUrl": self.thumbnail_url, + "SeriesTitle": self.series_title, + } \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index d1f1048..792bf85 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -18,6 +18,7 @@ from ..providers.auth import AuthContext, AuthStatus from .models.proxy_models import ProxyConfig from .models import DRMConfig, Event, StreamingChannel from .models.bookmark import Bookmark, ContentType +from .models.favorite import FavoriteType, Favorite from .models.subscription import SubscriptionPackage, UserSubscription from .models.recording import Recording from .models.timer import Timer @@ -1110,6 +1111,68 @@ class StreamingProvider(ABC): "Override this method to support timer deletion." ) + # ============================================================================ + # Favorite Management + # ============================================================================ + + @property + def implements_favorites(self) -> bool: + """ + True if this provider can store/retrieve user favorites. + + Default False. Override in providers that support favorites. + """ + return False + + def get_favorites(self, **kwargs) -> List["Favorite"]: + """ + Return all favorites for the authenticated user. + + Returns: + List of Favorite objects, or [] if not supported or none exist. + """ + return [] + + def add_favorite( + self, + content_id: str, + favorite_type: FavoriteType, + title: Optional[str] = None, + **kwargs, + ) -> "Favorite": + """ + Add a content to user's favorites. + + Args: + content_id: Content identifier. + favorite_type: Type of content (PROGRAM, CLIP, LIVE, EVENT). + title: Content title (optional for caching). + + Returns: + The saved Favorite object. + + Raises: + RuntimeError: If the provider rejects the favorite. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not implement add_favorite()." + ) + + def remove_favorite(self, content_id: str, **kwargs) -> None: + """ + Remove a content from user's favorites. + + Args: + content_id: Content identifier to remove. + + Raises: + KeyError: If no favorite with this content_id exists. + RuntimeError: If the provider refuses deletion. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not implement remove_favorite()." + ) + # ============================================================================ # CATCHUP ABSTRACT METHODS # ============================================================================ diff --git a/routes/favorites.py b/routes/favorites.py new file mode 100644 index 0000000..702fa6f --- /dev/null +++ b/routes/favorites.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Favorite-related route handlers. +Mirrors the structure of bookmarks.py. +""" + +from bottle import request, response +from streaming_providers.base.models.favorite import FavoriteType +from streaming_providers.base.utils import logger + + +def setup_favorites_routes(app, manager, service): + """Setup favorite-related routes.""" + + # ========================================================================== + # SINGLE PROVIDER ROUTES + # ========================================================================== + + @app.route("/api/providers//favorites", method="GET") + def get_provider_favorites(provider): + """ + Get favorites from a specific provider. + + Query parameters: + - favorite_type: Optional (PROGRAM, CLIP, LIVE, EVENT) + + Returns: + { + "provider": "provider_name", + "favorites": [ { ...Favorite fields... } ], + "count": 1, + "filters": { + "favorite_type": null + } + } + """ + try: + favorite_type_str = request.params.get("favorite_type") + favorite_type = None + if favorite_type_str: + try: + favorite_type = FavoriteType(favorite_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid favorite_type", + "message": f"favorite_type must be one of: {[ft.value for ft in FavoriteType]}", + "provider": provider, + } + + try: + favorites = manager.get_favorites( + provider_name=provider, + favorite_type=favorite_type, + ) + except ValueError as e: + response.status = 404 + return { + "error": "Provider not found", + "message": str(e), + "provider": provider, + } + except Exception as e: + logger.error(f"Failed to get favorites from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to get favorites", + "message": str(e), + "provider": provider, + } + + response.status = 200 + return { + "provider": provider, + "favorites": [f.to_dict() for f in favorites], + "count": len(favorites), + "filters": { + "favorite_type": favorite_type.value if favorite_type else None, + }, + } + + except Exception as e: + logger.error(f"Unexpected error in get_provider_favorites: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/providers//favorites/", method="GET") + def get_provider_favorite(provider, content_id): + """ + Check if a specific content ID is favorited. + + Returns: + { + "provider": "provider_name", + "content_id": "123", + "is_favorited": true, + "favorite": { ...Favorite fields... } // Only if favorited + } + """ + try: + try: + is_favorited = manager.is_favorited( + provider_name=provider, + content_id=content_id, + ) + # If favorited, get the full favorite object + favorite = None + if is_favorited: + favorites = manager.get_favorites(provider_name=provider) + for f in favorites: + if f.content_id == content_id: + favorite = f + break + except ValueError as e: + response.status = 404 + return {"error": "Provider not found", "message": str(e), "provider": provider} + except Exception as e: + logger.error(f"Failed to check favorite from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to check favorite", + "message": str(e), + "provider": provider, + } + + response.status = 200 + result = { + "provider": provider, + "content_id": content_id, + "is_favorited": is_favorited, + } + if favorite: + result["favorite"] = favorite.to_dict() + + return result + + except Exception as e: + logger.error(f"Unexpected error in get_provider_favorite: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/providers//favorites/", method="POST") + def add_provider_favorite(provider, content_id): + """ + Add a favorite. + + Request body (JSON): + { + "favorite_type": "PROGRAM", // Required + "title": "Show Title", // Optional + "thumbnail_url": "https://...", // Optional + "series_title": "Series Name" // Optional (for series/program) + } + + Returns: + { ...Favorite fields... } + """ + try: + data = request.json + if not data: + response.status = 400 + return { + "error": "Invalid request", + "message": "Request body is required", + "provider": provider, + "content_id": content_id, + } + + # --- favorite_type (required) --- + favorite_type_str = data.get("favorite_type") + if not favorite_type_str: + response.status = 400 + return { + "error": "Missing required field", + "message": "favorite_type is required", + "provider": provider, + "content_id": content_id, + } + try: + favorite_type = FavoriteType(favorite_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid favorite_type", + "message": f"favorite_type must be one of: {[ft.value for ft in FavoriteType]}", + "provider": provider, + "content_id": content_id, + } + + try: + favorite = manager.add_favorite( + provider_name=provider, + content_id=content_id, + favorite_type=favorite_type, + title=data.get("title"), + thumbnail_url=data.get("thumbnail_url"), + series_title=data.get("series_title"), + ) + except ValueError as e: + response.status = 404 + return { + "error": "Provider not found", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except RuntimeError as e: + response.status = 409 + return { + "error": "Favorite addition rejected", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except Exception as e: + logger.error(f"Failed to add favorite '{content_id}' to '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to add favorite", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + if favorite is None: + response.status = 501 + return { + "error": "Favorites not supported", + "message": f"Provider '{provider}' does not support favorites", + "provider": provider, + "content_id": content_id, + } + + response.status = 200 + return favorite.to_dict() + + except Exception as e: + logger.error(f"Unexpected error in add_provider_favorite: {e}") + response.status = 500 + return { + "error": "Internal server error", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + @app.route("/api/providers//favorites/", method="DELETE") + def remove_provider_favorite(provider, content_id): + """ + Remove a favorite. + + Returns: + 204 No Content on success. + 404 if the provider or favorite is not found. + 409 if the provider refuses deletion. + 500 on unexpected errors. + """ + try: + try: + removed = manager.remove_favorite( + provider_name=provider, + content_id=content_id, + ) + except ValueError as e: + response.status = 404 + return { + "error": "Provider not found", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except RuntimeError as e: + response.status = 409 + return { + "error": "Deletion refused by provider", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except Exception as e: + logger.error(f"Failed to remove favorite '{content_id}' from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to remove favorite", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + if not removed: + response.status = 404 + return { + "error": "Favorite not found", + "message": f"No favorite with content_id '{content_id}' from '{provider}'", + "provider": provider, + "content_id": content_id, + } + + response.status = 204 + return "" + + except Exception as e: + logger.error(f"Unexpected error in remove_provider_favorite: {e}") + response.status = 500 + return { + "error": "Internal server error", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + # ========================================================================== + # AGGREGATE ROUTES (ALL PROVIDERS) + # ========================================================================== + + @app.route("/api/favorites/all", method="GET") + def get_all_favorites(): + """ + Get favorites from all enabled providers. + + Query parameters: + - favorite_type: Optional (PROGRAM, CLIP, LIVE, EVENT) + + Returns: + { + "favorites": [ { ...Favorite fields... } ], + "count": 1, + "by_provider": { "provider_name": 1 }, + "filters": { "favorite_type": null }, + "errors": { ... } // Only present if any providers failed + } + """ + try: + favorite_type_str = request.params.get("favorite_type") + favorite_type = None + if favorite_type_str: + try: + favorite_type = FavoriteType(favorite_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid favorite_type", + "message": f"favorite_type must be one of: {[ft.value for ft in FavoriteType]}", + } + + all_data = manager.get_all_favorites(favorite_type=favorite_type) + errors = all_data.pop("_errors", None) + + # Flatten the list + flat_list = [f for favorites in all_data.values() for f in favorites] + + by_provider = {} + for favorite in flat_list: + by_provider[favorite.provider] = by_provider.get(favorite.provider, 0) + 1 + + result = { + "favorites": [f.to_dict() for f in flat_list], + "count": len(flat_list), + "by_provider": by_provider, + "filters": { + "favorite_type": favorite_type.value if favorite_type else None, + }, + } + if errors: + result["errors"] = errors + + response.status = 200 + return result + + except Exception as e: + logger.error(f"Unexpected error in get_all_favorites: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e)} + + # ========================================================================== + # BULK OPERATIONS ROUTES + # ========================================================================== + + @app.route("/api/favorites/clear", method="POST") + def clear_all_favorites(): + """ + Remove all favorites from all providers (or specific providers). + + Request body (JSON): + { + "provider_filter": ["rtl_de"] // Optional, list of provider name strings + } + + Returns: + { + "deleted": { "rtl_de": 5, "joyn_de": 3 }, + "total_deleted": 8 + } + """ + try: + data = request.json or {} + + provider_filter = data.get("provider_filter") + if provider_filter is not None: + if not isinstance(provider_filter, list) or not all( + isinstance(p, str) for p in provider_filter + ): + response.status = 400 + return { + "error": "Invalid provider_filter", + "message": "provider_filter must be a list of provider name strings", + } + + results = {} + total = 0 + + # Get all favorites first + all_favorites = manager.get_all_favorites() + errors = all_favorites.pop("_errors", None) + + for provider_name, favorites in all_favorites.items(): + if provider_filter and provider_name not in provider_filter: + continue + + deleted = 0 + for favorite in favorites: + try: + if manager.remove_favorite(provider_name, favorite.content_id): + deleted += 1 + except Exception as e: + logger.error(f"Failed to remove favorite {favorite.content_id}: {e}") + + if deleted > 0: + results[provider_name] = deleted + total += deleted + + response.status = 200 + return { + "deleted": results, + "total_deleted": total, + } + + except Exception as e: + logger.error(f"Unexpected error in clear_all_favorites: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e)} \ No newline at end of file diff --git a/service.py b/service.py index aca1ea5..25f603b 100644 --- a/service.py +++ b/service.py @@ -1705,6 +1705,7 @@ class UltimateService: from routes.recordings import setup_recordings_routes from routes.timers import setup_timers_routes from routes.bookmarks import setup_bookmarks_routes + from routes.favorites import setup_favorites_routes # Setup routes from separate modules setup_provider_routes(self.app, self.manager, self) @@ -1719,6 +1720,7 @@ class UltimateService: setup_recordings_routes(self.app, self.manager, self) setup_timers_routes(self.app, self.manager, self) setup_bookmarks_routes(self.app, self.manager, self) + setup_favorites_routes(app, manager, service) # Core UI routes @self.app.route("/config")