From e8bb56448cb9d109d7dc338d1c44fc2df26d1824 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 10 Apr 2026 22:37:56 +0200 Subject: [PATCH] Add bookmarks --- .../base/bookmark_operations.py | 606 +++++++++++++ lib/streaming_providers/base/manager.py | 280 ++++++ .../base/models/bookmark.py | 494 +++++++++++ lib/streaming_providers/base/provider.py | 202 +++++ routes/bookmarks.py | 800 ++++++++++++++++++ service.py | 2 + 6 files changed, 2384 insertions(+) create mode 100644 lib/streaming_providers/base/bookmark_operations.py create mode 100644 lib/streaming_providers/base/models/bookmark.py create mode 100644 routes/bookmarks.py diff --git a/lib/streaming_providers/base/bookmark_operations.py b/lib/streaming_providers/base/bookmark_operations.py new file mode 100644 index 0000000..db21c29 --- /dev/null +++ b/lib/streaming_providers/base/bookmark_operations.py @@ -0,0 +1,606 @@ +# streaming_providers/base/bookmark_operations.py +""" +Bookmark-related operations separated from core registry. +Mirrors the structure of RecordingOperations. +""" + +from typing import Dict, List, Optional + +from .models.bookmark import Bookmark, ContentType, ValidationLevel +from .utils.logger import logger + + +_VALID_SORT_FIELDS = {"last_updated", "created_at", "title", "provider"} + + +class BookmarkOperations: + """Handles all bookmark-related operations.""" + + def __init__(self, registry): + self.registry = registry + logger.debug("BookmarkOperations: Initialized") + + # ========================================================================== + # SINGLE PROVIDER OPERATIONS + # ========================================================================== + + def get_bookmarks( + self, + provider_name: str, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + ) -> List[Bookmark]: + """ + Get bookmarks from a specific provider. + + Args: + provider_name: Name of the provider to query. + content_type: Optional filter by content type (LIVE, VOD, EVENT, etc.). + include_completed: If True, include bookmarks marked as completed. + If False, completed bookmarks are filtered out. + include_stale: If True, include bookmarks older than max_age_hours. + If False, stale bookmarks are filtered out. + max_age_hours: Maximum age in hours before a bookmark is considered stale. + Only used when include_stale=False. + + Returns: + List of Bookmark objects. + + Raises: + ValueError: If the provider is not found or disabled. + """ + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + # Check if provider supports bookmarks + if not provider.implements_bookmarks: + logger.debug( + f"Provider '{provider_name}' does not implement bookmarks, returning empty list" + ) + return [] + + # Fetch bookmarks from provider + bookmarks = provider.get_bookmarks() + + # Apply filters + filtered = [] + for bookmark in bookmarks: + # Filter by content type + if content_type and bookmark.content_type != content_type: + continue + + # Filter out completed bookmarks if requested + if not include_completed and bookmark.is_completed: + continue + + # Filter out stale bookmarks if requested + if not include_stale and bookmark.is_stale(max_age_hours): + continue + + filtered.append(bookmark) + + logger.info( + f"Retrieved {len(filtered)} bookmarks from '{provider_name}' " + f"(filtered from {len(bookmarks)} total)" + ) + return filtered + + def get_bookmark( + self, provider_name: str, content_id: str + ) -> Optional[Bookmark]: + """ + Get a specific bookmark by content ID from a provider. + + Completed and stale bookmarks are included so that a bookmark is never + silently missed just because it is old or finished. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + + Returns: + Bookmark object if found, None otherwise. + + Raises: + ValueError: If the provider is not found or disabled. + """ + bookmarks = self.get_bookmarks( + provider_name, include_completed=True, include_stale=True + ) + for bookmark in bookmarks: + if bookmark.content_id == content_id: + return bookmark + return None + + def update_bookmark( + self, + provider_name: str, + content_id: str, + content_type: ContentType, + position_seconds: int, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Bookmark]: + """ + Update or create a bookmark for a specific content. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + content_type: Type of content (required — callers always know what + they are bookmarking; avoids costly provider API calls + to infer the type). + position_seconds: Playback position in seconds (0 = start, -1 = completed). + Content is also considered complete once position + reaches the model's COMPLETION_THRESHOLD (≥ 95 % by + default), so an explicit -1 is not strictly required. + duration_seconds: Total duration of the content (optional but recommended). + title: Content title for caching (optional). + **kwargs: Additional metadata (thumbnail_url, series_title, etc.). + + Returns: + Updated Bookmark object, or None if provider doesn't support bookmarks. + + Raises: + ValueError: If the provider is not found or disabled, or if + position_seconds is out of range. + RuntimeError: If the provider rejects the update. + """ + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.implements_bookmarks: + logger.warning( + f"Provider '{provider_name}' does not implement bookmarks, " + f"cannot update bookmark for '{content_id}'" + ) + return None + + if position_seconds < -1: + raise ValueError( + f"position_seconds must be >= -1, got {position_seconds}" + ) + + try: + bookmark = provider.update_bookmark( + content_id=content_id, + position_seconds=position_seconds, + duration_seconds=duration_seconds, + title=title, + content_type=content_type, + **kwargs, + ) + + logger.info( + f"Updated bookmark for '{content_id}' from '{provider_name}' " + f"at position {position_seconds}s" + ) + return bookmark + + except Exception as e: + logger.error(f"Failed to update bookmark for '{content_id}': {e}") + raise RuntimeError(f"Provider rejected bookmark update: {e}") from e + + def delete_bookmark( + self, provider_name: str, content_id: str + ) -> bool: + """ + Delete a bookmark from a specific provider. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + + Returns: + True if deleted, False if bookmark didn't exist or provider doesn't support. + + Raises: + ValueError: If the provider is not found or disabled. + RuntimeError: If the provider refuses deletion. + """ + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.implements_bookmarks: + logger.debug( + f"Provider '{provider_name}' does not implement bookmarks, " + f"cannot delete bookmark for '{content_id}'" + ) + return False + + try: + provider.delete_bookmark(content_id=content_id) + logger.info(f"Deleted bookmark for '{content_id}' from '{provider_name}'") + return True + except KeyError: + logger.debug(f"Bookmark for '{content_id}' not found on '{provider_name}'") + return False + except Exception as e: + logger.error(f"Failed to delete bookmark for '{content_id}': {e}") + raise RuntimeError(f"Provider refused bookmark deletion: {e}") from e + + def mark_completed( + self, + provider_name: str, + content_id: str, + content_type: ContentType, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Bookmark]: + """ + Mark a content as completed (watched to end). + + Sets position to -1, which the Bookmark model treats as explicitly + completed. Note that the model also considers content complete once + playback reaches the COMPLETION_THRESHOLD (≥ 95 % by default), so + this method is only needed when an explicit completion event occurs + before that threshold is reached. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + content_type: Type of content. + duration_seconds: Total duration (optional). + title: Content title (optional). + **kwargs: Additional metadata. + + Returns: + Updated Bookmark with position set to -1, or None if not supported. + + Raises: + ValueError: If the provider is not found or disabled. + """ + return self.update_bookmark( + provider_name=provider_name, + content_id=content_id, + content_type=content_type, + position_seconds=-1, + duration_seconds=duration_seconds, + title=title, + **kwargs, + ) + + # ========================================================================== + # AGGREGATE OPERATIONS (ALL PROVIDERS) + # ========================================================================== + + def get_all_bookmarks( + self, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + ) -> Dict[str, List[Bookmark]]: + """ + Get bookmarks from all enabled providers. + + Args: + content_type: Optional filter by content type. + include_completed: If True, include completed bookmarks. + include_stale: If True, include stale bookmarks. + max_age_hours: Maximum age for stale detection (when include_stale=False). + + Returns: + Dict mapping provider name → list of Bookmark objects. + Failed providers map to an empty list; check the 'errors' log for + details, or use the 'errors' key in the returned dict if you need + programmatic access to failures. + + Note: An empty list means either the provider has no bookmarks OR + the provider failed. To distinguish these cases, consult the + 'errors' entry in the returned dict (present only on failure). + """ + enabled = self.registry.get_enabled_providers() + logger.info(f"Fetching bookmarks from {len(enabled)} providers") + + result: Dict[str, List[Bookmark]] = {} + errors: Dict[str, str] = {} + total = 0 + + for name in enabled: + try: + bookmarks = self.get_bookmarks( + provider_name=name, + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + result[name] = bookmarks + total += len(bookmarks) + except Exception as e: + logger.error(f"Failed to get bookmarks from '{name}': {e}") + result[name] = [] + errors[name] = str(e) + + if errors: + result["_errors"] = errors # type: ignore[assignment] + + logger.info(f"Retrieved {total} total bookmarks") + return result + + def get_all_bookmarks_flat( + self, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + sort_by: str = "last_updated", + ) -> List[Bookmark]: + """ + Get all bookmarks as a flat list sorted by the specified field. + + Args: + content_type: Optional filter by content type. + include_completed: If True, include completed bookmarks. + include_stale: If True, include stale bookmarks. + max_age_hours: Maximum age for stale detection. + sort_by: Sort field. Must be one of: 'last_updated', 'created_at', + 'title', 'provider'. Date fields sort descending (newest + first); string fields sort ascending. + + Returns: + Flat list of Bookmark objects sorted by sort_by. + + Raises: + ValueError: If sort_by is not a recognised field. + """ + if sort_by not in _VALID_SORT_FIELDS: + raise ValueError( + f"Invalid sort_by '{sort_by}'. Must be one of: " + f"{sorted(_VALID_SORT_FIELDS)}" + ) + + all_bookmarks = self.get_all_bookmarks( + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + + # Flatten, skipping the internal _errors sentinel key + flat_list: List[Bookmark] = [] + for key, bookmarks in all_bookmarks.items(): + if key == "_errors": + continue + flat_list.extend(bookmarks) # type: ignore[arg-type] + + if sort_by == "last_updated": + flat_list.sort(key=lambda b: b.last_updated, reverse=True) + elif sort_by == "created_at": + flat_list.sort(key=lambda b: b.created_at, reverse=True) + elif sort_by == "title": + flat_list.sort(key=lambda b: b.title or "") + elif sort_by == "provider": + flat_list.sort(key=lambda b: b.provider) + + return flat_list + + # ========================================================================== + # BULK OPERATIONS + # ========================================================================== + + def cleanup_stale_bookmarks( + self, + max_age_hours: int = 720, + dry_run: bool = True, + provider_filter: Optional[List[str]] = None, + ) -> Dict[str, int]: + """ + Remove bookmarks older than max_age_hours. + + Args: + max_age_hours: Age threshold in hours (default 720 = 30 days). + dry_run: If True, only report what would be deleted without actually deleting. + provider_filter: Optional list of provider names to restrict cleanup. + + Returns: + Dict mapping provider name → number of bookmarks deleted + (or would-be deleted for dry_run). + """ + enabled = self.registry.get_enabled_providers() + if provider_filter: + enabled = [p for p in enabled if p in provider_filter] + + results = {} + + for name in enabled: + try: + # include_stale=False so get_bookmarks returns only stale ones, + # and include_completed=True so completed-but-stale entries are + # also cleaned up. + stale = self.get_bookmarks( + provider_name=name, + include_completed=True, + include_stale=False, + max_age_hours=max_age_hours, + ) + + if dry_run: + results[name] = len(stale) + if stale: + logger.info( + f"[DRY RUN] Would delete {len(stale)} stale bookmarks " + f"from '{name}'" + ) + else: + deleted = 0 + for bookmark in stale: + try: + self.delete_bookmark(name, bookmark.content_id) + deleted += 1 + except Exception as e: + logger.error( + f"Failed to delete stale bookmark '{bookmark.content_id}' " + f"from '{name}': {e}" + ) + results[name] = deleted + if deleted: + logger.info(f"Deleted {deleted} stale bookmarks from '{name}'") + + except Exception as e: + logger.error(f"Failed to cleanup bookmarks from '{name}': {e}") + results[name] = 0 + + return results + + def delete_all_bookmarks_for_content( + self, content_id: str, provider_filter: Optional[List[str]] = None + ) -> Dict[str, bool]: + """ + Delete bookmarks for a specific content ID across all providers. + + Useful when content is removed from a provider. delete_bookmark() handles + the not-found case gracefully (returns False), so no existence pre-check + is needed. + + Args: + content_id: Content identifier to delete. + provider_filter: Optional list of provider names to restrict deletion. + + Returns: + Dict mapping provider name → True if deleted, False if not found or + provider doesn't support bookmarks. + """ + enabled = self.registry.get_enabled_providers() + if provider_filter: + enabled = [p for p in enabled if p in provider_filter] + + results = {} + + for name in enabled: + try: + results[name] = self.delete_bookmark(name, content_id) + except Exception as e: + logger.error( + f"Failed to delete bookmark for '{content_id}' from '{name}': {e}" + ) + results[name] = False + + return results + + # ========================================================================== + # STATISTICS AND UTILITIES + # ========================================================================== + + def get_bookmark_stats(self, max_age_hours: int = 720) -> Dict: + """ + Get statistics about bookmarks across all providers. + + Args: + max_age_hours: Age threshold used for staleness classification. + Should match the value used in get_bookmarks() calls + so that stats are consistent with filtering behaviour. + + Returns: + Dictionary with counts by status, content type, and provider. + """ + all_bookmarks = self.get_all_bookmarks(include_completed=True, include_stale=True) + + stats = { + "total": 0, + "completed": 0, + "in_progress": 0, + "stale": 0, + "by_content_type": {}, + "by_provider": {}, + } + + for provider, bookmarks in all_bookmarks.items(): + if provider == "_errors": + continue + stats["by_provider"][provider] = len(bookmarks) + stats["total"] += len(bookmarks) + + for bookmark in bookmarks: # type: ignore[union-attr] + if bookmark.is_completed: + stats["completed"] += 1 + else: + stats["in_progress"] += 1 + + if bookmark.is_stale(max_age_hours): + stats["stale"] += 1 + + ct = bookmark.content_type.value + stats["by_content_type"][ct] = stats["by_content_type"].get(ct, 0) + 1 + + return stats + + def validate_bookmarks( + self, provider_name: str, auto_fix: bool = False + ) -> Dict: + """ + Validate all bookmarks from a provider and optionally fix common issues. + + Args: + provider_name: Name of the provider. + auto_fix: If True, attempt to fix fixable issues (e.g., position + exceeding duration). Note that the corrected position is + clipped to just below the model's COMPLETION_THRESHOLD so + that the auto-fix does not inadvertently mark content as + complete. + + Returns: + Dictionary with validation results:: + + { + "total": int, + "errors": List[Dict], # fatal issues + "warnings": List[Dict], # non-fatal issues + "fixed": int, # number of bookmarks auto-fixed + } + """ + bookmarks = self.get_bookmarks( + provider_name, include_completed=True, include_stale=True + ) + + results: Dict = { + "total": len(bookmarks), + "errors": [], + "warnings": [], + "fixed": 0, + } + + for bookmark in bookmarks: + issues = bookmark.validate() + for level, message in issues: + issue_info = { + "bookmark_id": bookmark.bookmark_id, + "content_id": bookmark.content_id, + "message": message, + } + + if level == ValidationLevel.ERROR: + results["errors"].append(issue_info) + else: + results["warnings"].append(issue_info) + + if auto_fix and level == ValidationLevel.WARNING: + if "exceeds duration" in message and bookmark.duration_seconds: + # Clip to just below the completion threshold so the fix + # does not accidentally mark the content as complete. + safe_max = int( + bookmark.duration_seconds + * bookmark.COMPLETION_THRESHOLD + ) - 1 + fixed_position = min(bookmark.position_seconds, safe_max) + try: + self.update_bookmark( + provider_name, + bookmark.content_id, + bookmark.content_type, + fixed_position, + bookmark.duration_seconds, + ) + results["fixed"] += 1 + except Exception: + pass + + return results \ No newline at end of file diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index 48ce9f8..f6d35b8 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -14,7 +14,9 @@ from .event_operations import EventOperations from .vod_operations import VodOperations from .recording_operations import RecordingOperations from .timer_operations import TimerOperations +from .bookmark_operations import BookmarkOperations from .models import StreamingChannel +from .models.bookmark import Bookmark, ContentType from .provider_registry import ProviderRegistry from .subscription_operations import SubscriptionOperations from .utils.logger import logger @@ -40,6 +42,7 @@ class ProviderManager: self.vod_ops = VodOperations(self.registry) self.recording_ops = RecordingOperations(self.registry) self.timer_ops = TimerOperations(self.registry) + self.bookmark_ops = BookmarkOperations(self.registry) # Backward compatibility - expose managers directly self.drm_plugin_manager = self.drm_ops.drm_plugin_manager @@ -347,6 +350,283 @@ class ProviderManager: provider_name, client_index, force_delete=force_delete, **kwargs ) + # ========================================================================== + # BOOKMARK OPERATIONS (delegate to BookmarkOperations) + # ========================================================================== + + def get_bookmarks( + self, + provider_name: str, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + ) -> List[Bookmark]: + """ + Get bookmarks from a specific provider. + + Args: + provider_name: Name of the provider to query. + content_type: Optional filter by content type. + include_completed: If True, include completed bookmarks. + include_stale: If True, include stale bookmarks. + max_age_hours: Maximum age before a bookmark is considered stale. + + Returns: + List of Bookmark objects. + + Raises: + ValueError: If the provider is not found or disabled. + """ + return self.bookmark_ops.get_bookmarks( + provider_name, + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + + def get_bookmark( + self, provider_name: str, content_id: str + ) -> Optional[Bookmark]: + """ + Get a specific bookmark by content ID from a provider. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + + Returns: + Bookmark object if found, None otherwise. + + Raises: + ValueError: If the provider is not found or disabled. + """ + return self.bookmark_ops.get_bookmark(provider_name, content_id) + + def update_bookmark( + self, + provider_name: str, + content_id: str, + content_type: ContentType, + position_seconds: int, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Bookmark]: + """ + Update or create a bookmark for a specific content. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + content_type: Type of content being bookmarked. + position_seconds: Playback position in seconds (0 = start, -1 = completed). + duration_seconds: Total duration of the content (optional). + title: Content title for caching (optional). + **kwargs: Additional metadata (thumbnail_url, series_title, etc.). + + Returns: + Updated Bookmark object, or None if provider doesn't support bookmarks. + + Raises: + ValueError: If the provider is not found or disabled. + RuntimeError: If the provider rejects the update. + """ + return self.bookmark_ops.update_bookmark( + provider_name, + content_id, + content_type, + position_seconds, + duration_seconds=duration_seconds, + title=title, + **kwargs, + ) + + def delete_bookmark( + self, provider_name: str, content_id: str + ) -> bool: + """ + Delete a bookmark from a specific provider. + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + + Returns: + True if deleted, False if bookmark didn't exist or provider doesn't support. + + Raises: + ValueError: If the provider is not found or disabled. + RuntimeError: If the provider refuses deletion. + """ + return self.bookmark_ops.delete_bookmark(provider_name, content_id) + + def mark_bookmark_completed( + self, + provider_name: str, + content_id: str, + content_type: ContentType, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> Optional[Bookmark]: + """ + Mark a content as completed (watched to end). + + Args: + provider_name: Name of the provider. + content_id: Content identifier. + content_type: Type of content. + duration_seconds: Total duration (optional). + title: Content title (optional). + **kwargs: Additional metadata. + + Returns: + Updated Bookmark with position set to -1, or None if not supported. + + Raises: + ValueError: If the provider is not found or disabled. + """ + return self.bookmark_ops.mark_completed( + provider_name, + content_id, + content_type, + duration_seconds=duration_seconds, + title=title, + **kwargs, + ) + + def get_all_bookmarks( + self, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + ) -> Dict[str, List[Bookmark]]: + """ + Get bookmarks from all enabled providers. + + Args: + content_type: Optional filter by content type. + include_completed: If True, include completed bookmarks. + include_stale: If True, include stale bookmarks. + max_age_hours: Maximum age for stale detection. + + Returns: + Dict mapping provider name → list of Bookmark objects. + Failed providers are represented by an '_errors' key. + """ + return self.bookmark_ops.get_all_bookmarks( + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + + def get_all_bookmarks_flat( + self, + content_type: Optional[ContentType] = None, + include_completed: bool = False, + include_stale: bool = False, + max_age_hours: int = 720, + sort_by: str = "last_updated", + ) -> List[Bookmark]: + """ + Get all bookmarks as a flat list sorted by the specified field. + + Args: + content_type: Optional filter by content type. + include_completed: If True, include completed bookmarks. + include_stale: If True, include stale bookmarks. + max_age_hours: Maximum age for stale detection. + sort_by: Sort field ('last_updated', 'created_at', 'title', 'provider'). + + Returns: + Flat list of Bookmark objects sorted by sort_by. + + Raises: + ValueError: If sort_by is not a recognised field. + """ + return self.bookmark_ops.get_all_bookmarks_flat( + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + sort_by=sort_by, + ) + + def delete_all_bookmarks_for_content( + self, + content_id: str, + provider_filter: Optional[List[str]] = None, + ) -> Dict[str, bool]: + """ + Delete bookmarks for a specific content ID across all providers. + + Useful when content is removed from a provider. + + Args: + content_id: Content identifier to delete. + provider_filter: Optional list of provider names to restrict deletion. + + Returns: + Dict mapping provider name → True if deleted, False if not found. + """ + return self.bookmark_ops.delete_all_bookmarks_for_content( + content_id, provider_filter=provider_filter + ) + + def cleanup_stale_bookmarks( + self, + max_age_hours: int = 720, + dry_run: bool = True, + provider_filter: Optional[List[str]] = None, + ) -> Dict[str, int]: + """ + Remove bookmarks older than max_age_hours. + + Args: + max_age_hours: Age threshold in hours (default 720 = 30 days). + dry_run: If True, only report what would be deleted without actually deleting. + provider_filter: Optional list of provider names to restrict cleanup. + + Returns: + Dict mapping provider name → number of bookmarks deleted (or would-be deleted). + """ + return self.bookmark_ops.cleanup_stale_bookmarks( + max_age_hours=max_age_hours, + dry_run=dry_run, + provider_filter=provider_filter, + ) + + def get_bookmark_stats(self, max_age_hours: int = 720) -> Dict[str, Any]: + """ + Get statistics about bookmarks across all providers. + + Args: + max_age_hours: Age threshold used for staleness classification. + + Returns: + Dictionary with counts by status, content type, and provider. + """ + return self.bookmark_ops.get_bookmark_stats(max_age_hours=max_age_hours) + + def validate_bookmarks( + self, provider_name: str, auto_fix: bool = False + ) -> Dict[str, Any]: + """ + Validate all bookmarks from a provider and optionally fix common issues. + + Args: + provider_name: Name of the provider. + auto_fix: If True, attempt to fix fixable issues. + + Returns: + Dictionary with validation results (total, errors, warnings, fixed). + """ + return self.bookmark_ops.validate_bookmarks(provider_name, auto_fix=auto_fix) + # ========================================================================== # SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations) # ========================================================================== diff --git a/lib/streaming_providers/base/models/bookmark.py b/lib/streaming_providers/base/models/bookmark.py new file mode 100644 index 0000000..245dd8d --- /dev/null +++ b/lib/streaming_providers/base/models/bookmark.py @@ -0,0 +1,494 @@ +# streaming_providers/base/models/bookmark.py +""" +Bookmark model. + +A Bookmark represents a saved playback position for any playable content +(live channels, VOD items, events, recordings). It acts as a pointer to +content rather than containing the content itself. + +Bookmarks are automatically updated when playback stops or pauses, and +can be used to implement "Continue Watching" features across all content +types and providers. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional, Tuple + + +class ContentType(str, Enum): + """Enum for content types that can be bookmarked.""" + LIVE = "LIVE" # Live channel (bookmark may represent last watched channel) + VOD = "VOD" # Video-on-demand movie or show + EVENT = "EVENT" # One-time event (sports, concert, etc.) + RECORDING = "RECORDING" # Captured broadcast recording + SERIES = "SERIES" # Series container (bookmark may represent last watched episode) + RADIO = "RADIO" # Radio stream + + +class ValidationLevel(str, Enum): + """Severity levels for validation messages.""" + ERROR = "ERROR" # Fatal: bookmark is unusable without fixing this + WARNING = "WARNING" # Non-fatal: bookmark works but behaviour may be unexpected + + +@dataclass(eq=False) +class Bookmark: + """ + A saved playback position pointing to playable content. + + Bookmarks are lightweight pointers that store where the user left off. + The actual content (manifest, DRM, metadata) is fetched separately + using the provider and content_id. + + This separation allows: + - Bookmark storage without duplicating content data + - Same bookmark structure across all content types + - Provider-independent bookmark management + - Client-side resolution of content details + + Serialization notes: + - ``to_dict()`` → PascalCase keys, suitable for API responses. + - ``to_storage_dict()`` → snake_case keys, suitable for storage backends. + Metadata fields (thumbnail, series info, channel + info) are intentionally omitted; they are expected + to be fetched fresh from the provider on restore. + - ``from_api_dict()`` → inverse of ``to_dict()`` (PascalCase input). + - ``from_storage_dict()``→ inverse of ``to_storage_dict()`` (snake_case input). + """ + + # ========================================================================== + # Core identification (what content this bookmark points to) + # ========================================================================== + + bookmark_id: str + """Unique identifier for this bookmark (composite 'provider:content_id').""" + + provider: str + """Provider name (e.g., 'rtl_de', 'joyn_at', 'zdf').""" + + content_id: str + """Provider-specific content identifier (channel ID, VOD ID, event ID, etc.).""" + + content_type: ContentType + """Type of content being bookmarked (see ContentType enum).""" + + # ========================================================================== + # Playback position (where the user stopped) + # ========================================================================== + + position_seconds: int = 0 + """ + Playback position in seconds from the start. + 0 = not started or start of content. + Negative values = completed (e.g., -1 indicates finished). + """ + + duration_seconds: Optional[int] = None + """Total duration of the content in seconds. Used for UI progress bars.""" + + # Threshold at which content is considered effectively complete (0.0–1.0). + COMPLETION_THRESHOLD: float = field(default=0.95, init=False, repr=False) + + # ========================================================================== + # Timestamps (when the bookmark was created/updated) + # ========================================================================== + + last_updated: datetime = field(default_factory=datetime.now) + """When this bookmark was last saved/updated.""" + + created_at: datetime = field(default_factory=datetime.now) + """When this bookmark was first created.""" + + # ========================================================================== + # Optional cached metadata for UI display (reduces API calls) + # ========================================================================== + + title: Optional[str] = None + """Title of the bookmarked content (cached for UI display).""" + + thumbnail_url: Optional[str] = None + """URL to thumbnail image (could be from the saved position or default).""" + + # Series/episode context (for VOD and RECORDING content types) + series_title: Optional[str] = None + season_number: Optional[int] = None + episode_number: Optional[int] = None + episode_name: Optional[str] = None + + # Channel context (for LIVE and RECORDING content types) + channel_name: Optional[str] = None + channel_logo: Optional[str] = None + + # ========================================================================== + # Properties + # ========================================================================== + + @property + def is_completed(self) -> bool: + """ + True if the user has finished watching this content. + + Content is considered complete when: + - position_seconds is negative (explicitly marked done), OR + - position has reached or exceeded the completion threshold (≥ 95 % by + default), avoiding a "perpetual 99.9 %" state for content that was + watched to the end without an explicit completion event. + """ + if self.position_seconds < 0: + return True + if ( + self.duration_seconds + and self.duration_seconds > 0 + and self.position_seconds >= self.duration_seconds * self.COMPLETION_THRESHOLD + ): + return True + return False + + @property + def progress_percent(self) -> Optional[float]: + """ + Calculate watch progress as a percentage. + + Returns: + 100.0 if completed; a value in [0, 100] if duration is known; + None if duration is unknown. + """ + if self.is_completed: + return 100.0 + if self.duration_seconds and self.duration_seconds > 0 and self.position_seconds >= 0: + return min((self.position_seconds / self.duration_seconds) * 100, 100.0) + return None + + @property + def remaining_seconds(self) -> Optional[int]: + """ + Calculate remaining watch time in seconds. + + Returns: + 0 if completed; remaining seconds if duration is known; None otherwise. + """ + if self.is_completed: + return 0 + if self.duration_seconds and self.duration_seconds > 0 and self.position_seconds >= 0: + return max(0, self.duration_seconds - self.position_seconds) + return None + + def is_stale(self, max_age_hours: int = 720) -> bool: + """ + Check if bookmark is stale (older than max_age_hours). + + Args: + max_age_hours: Maximum age in hours before bookmark is considered + stale. Default 720 hours = 30 days. + + Returns: + True if bookmark hasn't been updated in the specified period. + """ + age = datetime.now() - self.last_updated + return age.total_seconds() > (max_age_hours * 3600) + + @property + def composite_id(self) -> str: + """Return a composite identifier combining provider and content_id.""" + return f"{self.provider}:{self.content_id}" + + # ========================================================================== + # Factory methods + # ========================================================================== + + @classmethod + def create( + cls, + provider: str, + content_id: str, + content_type: ContentType, + position_seconds: int = 0, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> "Bookmark": + """ + Create a new bookmark with automatic ID generation. + + Args: + provider: Provider name. + content_id: Content identifier. + content_type: Type of content (see ContentType). + position_seconds: Playback position in seconds. + duration_seconds: Total duration in seconds. + title: Content title (cached for UI). + **kwargs: Additional metadata (thumbnail_url, series_title, etc.). + + Returns: + New Bookmark instance. + """ + now = datetime.now() + bookmark_id = f"{provider}:{content_id}" + return cls( + bookmark_id=bookmark_id, + provider=provider, + content_id=content_id, + content_type=content_type, + position_seconds=position_seconds, + duration_seconds=duration_seconds, + title=title, + last_updated=now, + created_at=now, + **kwargs, + ) + + @classmethod + def create_completed( + cls, + provider: str, + content_id: str, + content_type: ContentType, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> "Bookmark": + """ + Create a bookmark marking content as completed. + + Args: + provider: Provider name. + content_id: Content identifier. + content_type: Type of content. + duration_seconds: Total duration in seconds. + title: Content title. + **kwargs: Additional metadata. + + Returns: + Bookmark with position set to -1 (completed). + """ + return cls.create( + provider=provider, + content_id=content_id, + content_type=content_type, + position_seconds=-1, + duration_seconds=duration_seconds, + title=title, + **kwargs, + ) + + @classmethod + def from_api_dict(cls, data: Dict) -> "Bookmark": + """ + Create a Bookmark from a PascalCase API dictionary (inverse of ``to_dict``). + + Args: + data: Dictionary with PascalCase keys as returned by ``to_dict()``. + + Returns: + Bookmark instance. + """ + def _parse_dt(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value + return datetime.fromisoformat(value) + + return cls( + bookmark_id=data["BookmarkId"], + provider=data["Provider"], + content_id=data["ContentId"], + content_type=ContentType(data["ContentType"]), + position_seconds=data.get("PositionSeconds", 0), + duration_seconds=data.get("DurationSeconds"), + last_updated=_parse_dt(data.get("LastUpdated")) or datetime.now(), + created_at=_parse_dt(data.get("CreatedAt")) or datetime.now(), + title=data.get("Title"), + thumbnail_url=data.get("ThumbnailUrl"), + series_title=data.get("SeriesTitle"), + season_number=data.get("SeasonNumber"), + episode_number=data.get("EpisodeNumber"), + episode_name=data.get("EpisodeName"), + channel_name=data.get("ChannelName"), + channel_logo=data.get("ChannelLogo"), + ) + + @classmethod + def from_storage_dict(cls, data: Dict) -> "Bookmark": + """ + Create a Bookmark from a snake_case storage dictionary + (inverse of ``to_storage_dict``). + + Args: + data: Dictionary with snake_case keys as written by + ``to_storage_dict()``. + + Returns: + Bookmark instance. + """ + def _parse_dt(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value + return datetime.fromisoformat(value) + + return cls( + bookmark_id=data["bookmark_id"], + provider=data["provider"], + content_id=data["content_id"], + content_type=ContentType(data["content_type"]), + position_seconds=data.get("position_seconds", 0), + duration_seconds=data.get("duration_seconds"), + last_updated=_parse_dt(data.get("last_updated")) or datetime.now(), + created_at=_parse_dt(data.get("created_at")) or datetime.now(), + title=data.get("title"), + ) + + # ========================================================================== + # Serialization + # ========================================================================== + + def to_dict(self, include_none: bool = True) -> Dict: + """ + Convert bookmark to a PascalCase dictionary for API responses. + + Args: + include_none: When True (default) all fields are present so callers + can distinguish "field is absent" from "field is None". + Pass False for a compact payload that omits None values. + + Returns: + Dictionary with PascalCase keys. + """ + result = { + # Core identification + "BookmarkId": self.bookmark_id, + "Provider": self.provider, + "ContentId": self.content_id, + "ContentType": self.content_type.value, + + # Playback position + "PositionSeconds": self.position_seconds, + "DurationSeconds": self.duration_seconds, + "ProgressPercent": self.progress_percent, + "RemainingSeconds": self.remaining_seconds, + "IsCompleted": self.is_completed, + + # Timestamps + "LastUpdated": self.last_updated.isoformat(), + "CreatedAt": self.created_at.isoformat(), + + # Cached metadata + "Title": self.title, + "ThumbnailUrl": self.thumbnail_url, + + # Series context + "SeriesTitle": self.series_title, + "SeasonNumber": self.season_number, + "EpisodeNumber": self.episode_number, + "EpisodeName": self.episode_name, + + # Channel context + "ChannelName": self.channel_name, + "ChannelLogo": self.channel_logo, + } + + if not include_none: + return {k: v for k, v in result.items() if v is not None} + return result + + def to_storage_dict(self) -> Dict: + """ + Convert to a snake_case dictionary for storage backends (minimal fields). + + Metadata fields (thumbnail, series info, channel info) are intentionally + omitted — they are expected to be re-fetched from the provider on restore + so that cached data does not become stale across storage roundtrips. + + Returns: + Dictionary with only essential fields. + """ + return { + "bookmark_id": self.bookmark_id, + "provider": self.provider, + "content_id": self.content_id, + "content_type": self.content_type.value, + "position_seconds": self.position_seconds, + "duration_seconds": self.duration_seconds, + "last_updated": self.last_updated.isoformat(), + "created_at": self.created_at.isoformat(), + "title": self.title, + } + + # ========================================================================== + # Validation + # ========================================================================== + + def validate(self) -> List[Tuple[ValidationLevel, str]]: + """ + Validate bookmark data integrity. + + Returns: + List of (ValidationLevel, message) tuples. Empty list means valid. + ERROR entries indicate the bookmark is unusable without a fix. + WARNING entries indicate unexpected but non-fatal conditions. + """ + issues: List[Tuple[ValidationLevel, str]] = [] + + if not self.provider: + issues.append((ValidationLevel.ERROR, "Provider name is required")) + + if not self.content_id: + issues.append((ValidationLevel.ERROR, "Content ID is required")) + + if self.position_seconds < -1: + issues.append(( + ValidationLevel.ERROR, + f"Invalid position_seconds: {self.position_seconds} (must be >= -1)", + )) + + if self.duration_seconds is not None and self.duration_seconds <= 0: + issues.append(( + ValidationLevel.ERROR, + f"duration_seconds must be positive, got {self.duration_seconds}", + )) + + if ( + self.position_seconds > 0 + and self.duration_seconds + and self.position_seconds > self.duration_seconds + ): + issues.append(( + ValidationLevel.WARNING, + f"Position ({self.position_seconds}s) exceeds duration ({self.duration_seconds}s)", + )) + + if self.season_number is not None and self.season_number < 0: + issues.append(( + ValidationLevel.WARNING, + f"season_number must be >= 0, got {self.season_number}", + )) + + if self.episode_number is not None and self.episode_number < 0: + issues.append(( + ValidationLevel.WARNING, + f"episode_number must be >= 0, got {self.episode_number}", + )) + + return issues + + def is_valid(self) -> bool: + """Return True if the bookmark has no ERROR-level validation issues.""" + return not any(level == ValidationLevel.ERROR for level, _ in self.validate()) + + # ========================================================================== + # Comparison + # ========================================================================== + + def __eq__(self, other) -> bool: + """Two bookmarks are equal if they point to the same content.""" + if not isinstance(other, Bookmark): + return False + return self.provider == other.provider and self.content_id == other.content_id + + def __hash__(self) -> int: + """Hash based on provider and content_id.""" + return hash((self.provider, self.content_id)) \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 55e5fc5..0873653 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -17,6 +17,7 @@ from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple 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.subscription import SubscriptionPackage, UserSubscription from .models.recording import Recording from .models.timer import Timer @@ -693,6 +694,207 @@ class StreamingProvider(ABC): """ return [] + # ========================================================================= + # BOOKMARKS + # ========================================================================= + + @property + def implements_bookmarks(self) -> bool: + """ + True if this provider can store/retrieve playback positions. + + Return False (default) for providers that do not have bookmark / + continue-watching functionality. BookmarkOperations will skip + providers where this returns False when aggregating across all + providers. + + Providers that support bookmarks should override this to return True + and implement get_bookmarks(), update_bookmark(), and + delete_bookmark(). + """ + return False + + def get_bookmarks(self, **kwargs) -> List[Bookmark]: + """ + Return all bookmarks for the authenticated user. + + This method should fetch the user's continue-watching list or + playback positions from the provider's backend. + + Args: + **kwargs: Provider-specific filtering options (e.g., content_type, + limit, offset). + + Returns: + List of Bookmark objects, or [] if bookmarks are not supported + or none exist. + + Note: + The returned Bookmark objects should have their content_type field + properly set (LIVE, VOD, EVENT, RECORDING, etc.) so that the + client can correctly resolve the content. + + Default implementation returns an empty list. Override in provider + plugins that support bookmark storage. + """ + return [] + + def update_bookmark( + self, + content_id: str, + position_seconds: int, + content_type: ContentType, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kwargs, + ) -> Bookmark: + """ + Save or update a bookmark for specific content. + + This method is called automatically when playback stops or pauses. + The provider should store the position and associate it with the + authenticated user. + + Args: + content_id: The content being watched (channel ID, VOD ID, + etc.). + position_seconds: Where playback stopped in seconds from start. + 0 = not started / start of content. + -1 = explicitly marked as completed. + Content is also considered complete once position + reaches the model's COMPLETION_THRESHOLD + (>=95% by default). + content_type: Type of content being bookmarked. Required — + callers always know what they are bookmarking, + so this is never None. + duration_seconds: Total duration of the content (optional but + recommended for progress calculations). + title: Content title for caching (optional — provider + may ignore and use its own metadata store). + **kwargs: Provider-specific arguments (e.g., episode + number, season number, series ID). + + Returns: + The saved Bookmark object as confirmed by the provider. + + Raises: + RuntimeError: If the provider rejects the bookmark (e.g. user not + authenticated, content not accessible). + + Note: + At minimum the provider should persist content_id and + position_seconds so that get_bookmarks() can later return this + bookmark. + + Default implementation raises NotImplementedError so misconfigured + providers fail loudly rather than silently doing nothing. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not implement update_bookmark(). " + "Override this method to support bookmark storage." + ) + + def delete_bookmark(self, content_id: str, **kwargs) -> None: + """ + Delete a bookmark from the provider's backend. + + Called when: + - User manually removes a bookmark from "Continue Watching" + - Content is removed from the provider + - Cleanup of stale bookmarks + + Args: + content_id: The content identifier whose bookmark should be + removed. + **kwargs: Provider-specific arguments. + + Returns: + None on success. + + Raises: + KeyError: If no bookmark with this content_id exists. + RuntimeError: If the provider refuses deletion (e.g. permission + denied, backend error). + + Note: + Deleting a non-existent bookmark must raise KeyError rather than + silently succeeding, so that callers can distinguish "already + gone" from "successfully deleted". + + Default implementation raises NotImplementedError so misconfigured + providers fail loudly. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not implement delete_bookmark(). " + "Override this method to support bookmark deletion." + ) + + # ========================================================================= + # BOOKMARK HELPER METHODS (Optional overrides) + # ========================================================================= + + def batch_update_bookmarks( + self, updates: List[Dict[str, Any]], **kwargs + ) -> List[Bookmark]: + """ + Update multiple bookmarks in a single batch operation. + + Useful for synchronising local bookmark state with the provider + backend, or for bulk writes after a playback session. + + Args: + updates: List of dictionaries, each containing:: + + { + "content_id": str, + "position_seconds": int, + "content_type": ContentType, + "duration_seconds": Optional[int], + "title": Optional[str], + ... (other provider-specific kwargs) + } + + **kwargs: Provider-specific batch options passed to every + individual update_bookmark() call. + + Returns: + List of updated Bookmark objects in the same order as ``updates``. + If an individual update fails, the exception propagates and the + list contains only the bookmarks that succeeded before the + failure. + + Raises: + RuntimeError: If the batch operation fails partially or completely. + + Note: + The default implementation falls back to individual + update_bookmark() calls and does **not** mutate the dicts in + ``updates``. Override if your provider supports a native batch + endpoint for efficiency. + """ + results = [] + for raw in updates: + # Work on a copy so the caller's dicts are never mutated. + update = raw.copy() + content_id = update.pop("content_id") + position_seconds = update.pop("position_seconds") + content_type = update.pop("content_type") + duration_seconds = update.pop("duration_seconds", None) + title = update.pop("title", None) + + result = self.update_bookmark( + content_id=content_id, + position_seconds=position_seconds, + content_type=content_type, + duration_seconds=duration_seconds, + title=title, + **update, + **kwargs, + ) + results.append(result) + + return results + # ========================================================================= # RECORDINGS # ========================================================================= diff --git a/routes/bookmarks.py b/routes/bookmarks.py new file mode 100644 index 0000000..4ce86b9 --- /dev/null +++ b/routes/bookmarks.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +""" +Bookmark-related route handlers. +Mirrors the structure of recordings.py. +""" + +from bottle import request, response +from streaming_providers.base.models.bookmark import ContentType +from streaming_providers.base.utils import logger + + +def setup_bookmarks_routes(app, manager, service): + """Setup bookmark-related routes.""" + + # ========================================================================== + # SINGLE PROVIDER ROUTES + # ========================================================================== + + @app.route("/api/providers//bookmarks", method="GET") + def get_provider_bookmarks(provider): + """ + Get bookmarks from a specific provider. + + Query parameters: + - content_type: Optional (LIVE, VOD, EVENT, RECORDING, SERIES, RADIO) + - include_completed: Optional bool (true/false) — default false + - include_stale: Optional bool (true/false) — default false + - max_age_hours: Optional int — default 720 (30 days) + + Returns: + { + "provider": "provider_name", + "bookmarks": [ { ...Bookmark fields... } ], + "count": 1, + "filters": { + "content_type": null, + "include_completed": false, + "include_stale": false, + "max_age_hours": 720 + } + } + """ + try: + content_type_str = request.params.get("content_type") + content_type = None + if content_type_str: + try: + content_type = ContentType(content_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid content_type", + "message": f"content_type must be one of: {[ct.value for ct in ContentType]}", + "provider": provider, + } + + include_completed = request.params.get("include_completed", "false").lower() in ( + "1", "true", "yes" + ) + include_stale = request.params.get("include_stale", "false").lower() in ( + "1", "true", "yes" + ) + + try: + max_age_hours = int(request.params.get("max_age_hours", "720")) + if max_age_hours < 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid max_age_hours", + "message": "max_age_hours must be a non-negative integer", + "provider": provider, + } + + try: + bookmarks = manager.get_bookmarks( + provider_name=provider, + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + 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 bookmarks from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to get bookmarks", + "message": str(e), + "provider": provider, + } + + response.status = 200 + return { + "provider": provider, + "bookmarks": [b.to_dict(include_none=False) for b in bookmarks], + "count": len(bookmarks), + "filters": { + "content_type": content_type.value if content_type else None, + "include_completed": include_completed, + "include_stale": include_stale, + "max_age_hours": max_age_hours, + }, + } + + except Exception as e: + logger.error(f"Unexpected error in get_provider_bookmarks: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/providers//bookmarks/", method="GET") + def get_provider_bookmark(provider, content_id): + """ + Get a single bookmark by content ID from a specific provider. + + Returns: + { ...Bookmark fields... } + """ + try: + try: + bookmark = manager.get_bookmark( + provider_name=provider, + content_id=content_id, + ) + 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 bookmark from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to get bookmark", + "message": str(e), + "provider": provider, + } + + if not bookmark: + response.status = 404 + return { + "error": "Bookmark not found", + "message": f"No bookmark with content_id '{content_id}' from '{provider}'", + "provider": provider, + "content_id": content_id, + } + + response.status = 200 + return bookmark.to_dict(include_none=False) + + except Exception as e: + logger.error(f"Unexpected error in get_provider_bookmark: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/providers//bookmarks/", method="POST") + def update_provider_bookmark(provider, content_id): + """ + Update or create a bookmark for a specific content. + + Request body (JSON): + { + "content_type": "VOD", // Required + "position_seconds": 120, // Required: >= -1 + "duration_seconds": 3600, // Optional: positive integer + "title": "Movie Title", // Optional + "thumbnail_url": "https://...", // Optional + "series_title": "Series Name", // Optional + "season_number": 1, // Optional + "episode_number": 5, // Optional + "episode_name": "Episode Title",// Optional + "channel_name": "Channel Name", // Optional + "channel_logo": "https://..." // Optional + } + + Returns: + { ...Updated Bookmark 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, + } + + # --- content_type (required) --- + content_type_str = data.get("content_type") + if not content_type_str: + response.status = 400 + return { + "error": "Missing required field", + "message": "content_type is required", + "provider": provider, + "content_id": content_id, + } + try: + content_type = ContentType(content_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid content_type", + "message": f"content_type must be one of: {[ct.value for ct in ContentType]}", + "provider": provider, + "content_id": content_id, + } + + # --- position_seconds (required) --- + position_seconds = data.get("position_seconds") + if position_seconds is None: + response.status = 400 + return { + "error": "Missing required field", + "message": "position_seconds is required", + "provider": provider, + "content_id": content_id, + } + try: + position_seconds = int(position_seconds) + if position_seconds < -1: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid position_seconds", + "message": "position_seconds must be an integer >= -1", + "provider": provider, + "content_id": content_id, + } + + # --- duration_seconds (optional, must be positive if present) --- + duration_seconds = data.get("duration_seconds") + if duration_seconds is not None: + try: + duration_seconds = int(duration_seconds) + if duration_seconds <= 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid duration_seconds", + "message": "duration_seconds must be a positive integer", + "provider": provider, + "content_id": content_id, + } + + try: + bookmark = manager.update_bookmark( + provider_name=provider, + content_id=content_id, + content_type=content_type, + position_seconds=position_seconds, + duration_seconds=duration_seconds, + title=data.get("title"), + thumbnail_url=data.get("thumbnail_url"), + series_title=data.get("series_title"), + season_number=data.get("season_number"), + episode_number=data.get("episode_number"), + episode_name=data.get("episode_name"), + channel_name=data.get("channel_name"), + channel_logo=data.get("channel_logo"), + ) + 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": "Bookmark update rejected", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except Exception as e: + logger.error(f"Failed to update bookmark '{content_id}' from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to update bookmark", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + if bookmark is None: + response.status = 501 + return { + "error": "Bookmarks not supported", + "message": f"Provider '{provider}' does not support bookmarks", + "provider": provider, + "content_id": content_id, + } + + response.status = 200 + return bookmark.to_dict(include_none=False) + + except Exception as e: + logger.error(f"Unexpected error in update_provider_bookmark: {e}") + response.status = 500 + return { + "error": "Internal server error", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + @app.route("/api/providers//bookmarks//complete", method="POST") + def mark_bookmark_completed(provider, content_id): + """ + Mark a bookmark as completed (watched to end). + + Request body (JSON): + { + "content_type": "VOD", // Required + "duration_seconds": 3600, // Optional: positive integer + "title": "Movie Title" // Optional + } + + Returns: + { ...Updated Bookmark fields... } + """ + try: + data = request.json or {} + + # --- content_type (required) --- + content_type_str = data.get("content_type") + if not content_type_str: + response.status = 400 + return { + "error": "Missing required field", + "message": "content_type is required", + "provider": provider, + "content_id": content_id, + } + try: + content_type = ContentType(content_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid content_type", + "message": f"content_type must be one of: {[ct.value for ct in ContentType]}", + "provider": provider, + "content_id": content_id, + } + + # --- duration_seconds (optional, must be positive if present) --- + duration_seconds = data.get("duration_seconds") + if duration_seconds is not None: + try: + duration_seconds = int(duration_seconds) + if duration_seconds <= 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid duration_seconds", + "message": "duration_seconds must be a positive integer", + "provider": provider, + "content_id": content_id, + } + + try: + bookmark = manager.mark_bookmark_completed( + provider_name=provider, + content_id=content_id, + content_type=content_type, + duration_seconds=duration_seconds, + title=data.get("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": "Bookmark update rejected", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + except Exception as e: + logger.error(f"Failed to mark bookmark '{content_id}' as completed: {e}") + response.status = 500 + return { + "error": "Failed to mark bookmark as completed", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + if bookmark is None: + response.status = 501 + return { + "error": "Bookmarks not supported", + "message": f"Provider '{provider}' does not support bookmarks", + "provider": provider, + "content_id": content_id, + } + + response.status = 200 + return bookmark.to_dict(include_none=False) + + except Exception as e: + logger.error(f"Unexpected error in mark_bookmark_completed: {e}") + response.status = 500 + return { + "error": "Internal server error", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + @app.route("/api/providers//bookmarks/", method="DELETE") + def delete_provider_bookmark(provider, content_id): + """ + Delete a bookmark from a specific provider. + + Returns: + 204 No Content on success. + 404 if the provider or bookmark is not found. + 409 if the provider refuses deletion. + 500 on unexpected errors. + """ + try: + try: + deleted = manager.delete_bookmark( + 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 delete bookmark '{content_id}' from '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to delete bookmark", + "message": str(e), + "provider": provider, + "content_id": content_id, + } + + # delete_bookmark returns False when the bookmark didn't exist or + # the provider doesn't support bookmarks — both map to 404. + if not deleted: + response.status = 404 + return { + "error": "Bookmark not found", + "message": f"No bookmark 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 delete_provider_bookmark: {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/bookmarks/all", method="GET") + def get_all_bookmarks(): + """ + Get bookmarks from all enabled providers. + + Query parameters: + - content_type: Optional (LIVE, VOD, EVENT, RECORDING, SERIES, RADIO) + - include_completed: Optional bool (true/false) — default false + - include_stale: Optional bool (true/false) — default false + - max_age_hours: Optional int — default 720 (30 days) + - sort_by: Optional — 'last_updated', 'created_at', 'title', + 'provider'. Default: 'last_updated' + + Returns: + { + "bookmarks": [ { ...Bookmark fields... } ], + "count": 1, + "by_provider": { "provider_name": 1 }, + "filters": { ... }, + "errors": { ... } // Only present if any providers failed + } + """ + try: + content_type_str = request.params.get("content_type") + content_type = None + if content_type_str: + try: + content_type = ContentType(content_type_str.upper()) + except ValueError: + response.status = 400 + return { + "error": "Invalid content_type", + "message": f"content_type must be one of: {[ct.value for ct in ContentType]}", + } + + include_completed = request.params.get("include_completed", "false").lower() in ( + "1", "true", "yes" + ) + include_stale = request.params.get("include_stale", "false").lower() in ( + "1", "true", "yes" + ) + + try: + max_age_hours = int(request.params.get("max_age_hours", "720")) + if max_age_hours < 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid max_age_hours", + "message": "max_age_hours must be a non-negative integer", + } + + sort_by = request.params.get("sort_by", "last_updated") + if sort_by not in ("last_updated", "created_at", "title", "provider"): + response.status = 400 + return { + "error": "Invalid sort_by", + "message": "sort_by must be one of: last_updated, created_at, title, provider", + } + + # Single call: fetch the dict, extract errors, derive sorted flat list + # from the same data — avoids making two full provider round-trips. + all_data = manager.get_all_bookmarks( + content_type=content_type, + include_completed=include_completed, + include_stale=include_stale, + max_age_hours=max_age_hours, + ) + errors = all_data.pop("_errors", None) + + # Flatten and sort + flat_list = [b for bookmarks in all_data.values() for b in bookmarks] + reverse = sort_by in ("last_updated", "created_at") + key_fn = { + "last_updated": lambda b: b.last_updated, + "created_at": lambda b: b.created_at, + "title": lambda b: b.title or "", + "provider": lambda b: b.provider, + }[sort_by] + flat_list.sort(key=key_fn, reverse=reverse) + + by_provider = {} + for bookmark in flat_list: + by_provider[bookmark.provider] = by_provider.get(bookmark.provider, 0) + 1 + + result = { + "bookmarks": [b.to_dict(include_none=False) for b in flat_list], + "count": len(flat_list), + "by_provider": by_provider, + "filters": { + "content_type": content_type.value if content_type else None, + "include_completed": include_completed, + "include_stale": include_stale, + "max_age_hours": max_age_hours, + "sort_by": sort_by, + }, + } + if errors: + result["errors"] = errors + + response.status = 200 + return result + + except Exception as e: + logger.error(f"Unexpected error in get_all_bookmarks: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e)} + + # ========================================================================== + # STATISTICS AND MAINTENANCE ROUTES + # ========================================================================== + + @app.route("/api/bookmarks/stats", method="GET") + def get_bookmark_stats(): + """ + Get statistics about bookmarks across all providers. + + Query parameters: + - max_age_hours: Optional int — default 720 (30 days) + + Returns: + { + "total": 42, + "completed": 10, + "in_progress": 32, + "stale": 5, + "by_content_type": { "VOD": 30, "EVENT": 8, "RECORDING": 4 }, + "by_provider": { "rtl_de": 20, "joyn_de": 15, "zdf": 7 } + } + """ + try: + try: + max_age_hours = int(request.params.get("max_age_hours", "720")) + if max_age_hours < 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid max_age_hours", + "message": "max_age_hours must be a non-negative integer", + } + + response.status = 200 + return manager.get_bookmark_stats(max_age_hours=max_age_hours) + + except Exception as e: + logger.error(f"Unexpected error in get_bookmark_stats: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e)} + + @app.route("/api/providers//bookmarks/validate", method="GET") + def validate_provider_bookmarks(provider): + """ + Validate all bookmarks from a provider (read-only). + + Returns validation issues without modifying any data. To auto-fix + issues, use POST /api/providers//bookmarks/fix instead. + + Returns: + { + "total": 10, + "errors": [...], + "warnings": [...], + "fixed": 0 + } + """ + try: + try: + result = manager.validate_bookmarks(provider_name=provider, auto_fix=False) + 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 validate bookmarks for '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to validate bookmarks", + "message": str(e), + "provider": provider, + } + + response.status = 200 + return result + + except Exception as e: + logger.error(f"Unexpected error in validate_provider_bookmarks: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/providers//bookmarks/fix", method="POST") + def fix_provider_bookmarks(provider): + """ + Validate and auto-fix bookmark issues for a provider. + + Auto-fix clips positions that exceed duration to just below the + completion threshold, preventing unintended completion marking. + Only WARNING-level issues are fixed; ERROR-level issues require + manual intervention. + + Returns: + { + "total": 10, + "errors": [...], + "warnings": [...], + "fixed": 2 + } + """ + try: + try: + result = manager.validate_bookmarks(provider_name=provider, auto_fix=True) + 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 fix bookmarks for '{provider}': {e}") + response.status = 500 + return { + "error": "Failed to fix bookmarks", + "message": str(e), + "provider": provider, + } + + response.status = 200 + return result + + except Exception as e: + logger.error(f"Unexpected error in fix_provider_bookmarks: {e}") + response.status = 500 + return {"error": "Internal server error", "message": str(e), "provider": provider} + + @app.route("/api/bookmarks/cleanup", method="POST") + def cleanup_stale_bookmarks(): + """ + Remove stale bookmarks older than max_age_hours. + + Request body (JSON): + { + "max_age_hours": 720, // Optional, default 720 + "dry_run": true, // Optional, default true + "provider_filter": ["rtl_de"] // Optional, list of provider name strings + } + + Returns: + { + "deleted": { "rtl_de": 5, "joyn_de": 3 }, + "total_deleted": 8, + "dry_run": false + } + """ + try: + data = request.json or {} + + try: + max_age_hours = int(data.get("max_age_hours", 720)) + if max_age_hours < 0: + raise ValueError + except ValueError: + response.status = 400 + return { + "error": "Invalid max_age_hours", + "message": "max_age_hours must be a non-negative integer", + } + + dry_run = data.get("dry_run", True) + if not isinstance(dry_run, bool): + response.status = 400 + return { + "error": "Invalid dry_run", + "message": "dry_run must be a boolean", + } + + 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 = manager.cleanup_stale_bookmarks( + max_age_hours=max_age_hours, + dry_run=dry_run, + provider_filter=provider_filter, + ) + + response.status = 200 + return { + "deleted": results, + "total_deleted": sum(results.values()), + "dry_run": dry_run, + } + + except Exception as e: + logger.error(f"Unexpected error in cleanup_stale_bookmarks: {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 21c1d20..afb581d 100644 --- a/service.py +++ b/service.py @@ -1701,6 +1701,7 @@ class UltimateService: from routes.vod import setup_vod_routes from routes.recordings import setup_recordings_routes from routes.timers import setup_timers_routes + from routes.bookmarks import setup_bookmarks_routes # Setup routes from separate modules setup_provider_routes(self.app, self.manager, self) @@ -1714,6 +1715,7 @@ class UltimateService: setup_vod_routes(self.app, self.manager) setup_recordings_routes(self.app, self.manager, self) setup_timers_routes(self.app, self.manager, self) + setup_bookmarks_routes(self.app, self.manager, self) # Core UI routes @self.app.route("/config")