Add bookmarks

This commit is contained in:
Nirvana
2026-04-10 22:37:56 +02:00
parent 0417aaa969
commit e8bb56448c
6 changed files with 2384 additions and 0 deletions
@@ -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
+280
View File
@@ -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)
# ==========================================================================
@@ -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.01.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))
+202
View File
@@ -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
# =========================================================================
+800
View File
@@ -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/<provider>/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/<provider>/bookmarks/<content_id>", 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/<provider>/bookmarks/<content_id>", 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/<provider>/bookmarks/<content_id>/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/<provider>/bookmarks/<content_id>", 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/<provider>/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/<provider>/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/<provider>/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)}
+2
View File
@@ -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")