mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-05 23:42:57 +02:00
494 lines
18 KiB
Python
494 lines
18 KiB
Python
# 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))
|