mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 06:02:35 +02:00
Add bookmarks
This commit is contained in:
@@ -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
|
||||
@@ -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.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))
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user