Backend add favorites

This commit is contained in:
Nirvana
2026-05-15 16:17:32 +02:00
parent 1ec831e858
commit fd7aab5319
6 changed files with 733 additions and 0 deletions
@@ -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)
+47
View File
@@ -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)
# ==========================================================================
@@ -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,
}
+63
View File
@@ -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
# ============================================================================
+441
View File
@@ -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/<provider>/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/<provider>/favorites/<content_id>", 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/<provider>/favorites/<content_id>", 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/<provider>/favorites/<content_id>", 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)}
+2
View File
@@ -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")