Files
2026-04-10 22:37:56 +02:00

494 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/base/models/bookmark.py
"""
Bookmark model.
A Bookmark represents a saved playback position for any playable content
(live channels, VOD items, events, recordings). It acts as a pointer to
content rather than containing the content itself.
Bookmarks are automatically updated when playback stops or pauses, and
can be used to implement "Continue Watching" features across all content
types and providers.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Tuple
class ContentType(str, Enum):
"""Enum for content types that can be bookmarked."""
LIVE = "LIVE" # Live channel (bookmark may represent last watched channel)
VOD = "VOD" # Video-on-demand movie or show
EVENT = "EVENT" # One-time event (sports, concert, etc.)
RECORDING = "RECORDING" # Captured broadcast recording
SERIES = "SERIES" # Series container (bookmark may represent last watched episode)
RADIO = "RADIO" # Radio stream
class ValidationLevel(str, Enum):
"""Severity levels for validation messages."""
ERROR = "ERROR" # Fatal: bookmark is unusable without fixing this
WARNING = "WARNING" # Non-fatal: bookmark works but behaviour may be unexpected
@dataclass(eq=False)
class Bookmark:
"""
A saved playback position pointing to playable content.
Bookmarks are lightweight pointers that store where the user left off.
The actual content (manifest, DRM, metadata) is fetched separately
using the provider and content_id.
This separation allows:
- Bookmark storage without duplicating content data
- Same bookmark structure across all content types
- Provider-independent bookmark management
- Client-side resolution of content details
Serialization notes:
- ``to_dict()`` → PascalCase keys, suitable for API responses.
- ``to_storage_dict()`` → snake_case keys, suitable for storage backends.
Metadata fields (thumbnail, series info, channel
info) are intentionally omitted; they are expected
to be fetched fresh from the provider on restore.
- ``from_api_dict()`` → inverse of ``to_dict()`` (PascalCase input).
- ``from_storage_dict()``→ inverse of ``to_storage_dict()`` (snake_case input).
"""
# ==========================================================================
# Core identification (what content this bookmark points to)
# ==========================================================================
bookmark_id: str
"""Unique identifier for this bookmark (composite 'provider:content_id')."""
provider: str
"""Provider name (e.g., 'rtl_de', 'joyn_at', 'zdf')."""
content_id: str
"""Provider-specific content identifier (channel ID, VOD ID, event ID, etc.)."""
content_type: ContentType
"""Type of content being bookmarked (see ContentType enum)."""
# ==========================================================================
# Playback position (where the user stopped)
# ==========================================================================
position_seconds: int = 0
"""
Playback position in seconds from the start.
0 = not started or start of content.
Negative values = completed (e.g., -1 indicates finished).
"""
duration_seconds: Optional[int] = None
"""Total duration of the content in seconds. Used for UI progress bars."""
# Threshold at which content is considered effectively complete (0.01.0).
COMPLETION_THRESHOLD: float = field(default=0.95, init=False, repr=False)
# ==========================================================================
# Timestamps (when the bookmark was created/updated)
# ==========================================================================
last_updated: datetime = field(default_factory=datetime.now)
"""When this bookmark was last saved/updated."""
created_at: datetime = field(default_factory=datetime.now)
"""When this bookmark was first created."""
# ==========================================================================
# Optional cached metadata for UI display (reduces API calls)
# ==========================================================================
title: Optional[str] = None
"""Title of the bookmarked content (cached for UI display)."""
thumbnail_url: Optional[str] = None
"""URL to thumbnail image (could be from the saved position or default)."""
# Series/episode context (for VOD and RECORDING content types)
series_title: Optional[str] = None
season_number: Optional[int] = None
episode_number: Optional[int] = None
episode_name: Optional[str] = None
# Channel context (for LIVE and RECORDING content types)
channel_name: Optional[str] = None
channel_logo: Optional[str] = None
# ==========================================================================
# Properties
# ==========================================================================
@property
def is_completed(self) -> bool:
"""
True if the user has finished watching this content.
Content is considered complete when:
- position_seconds is negative (explicitly marked done), OR
- position has reached or exceeded the completion threshold (≥ 95 % by
default), avoiding a "perpetual 99.9 %" state for content that was
watched to the end without an explicit completion event.
"""
if self.position_seconds < 0:
return True
if (
self.duration_seconds
and self.duration_seconds > 0
and self.position_seconds >= self.duration_seconds * self.COMPLETION_THRESHOLD
):
return True
return False
@property
def progress_percent(self) -> Optional[float]:
"""
Calculate watch progress as a percentage.
Returns:
100.0 if completed; a value in [0, 100] if duration is known;
None if duration is unknown.
"""
if self.is_completed:
return 100.0
if self.duration_seconds and self.duration_seconds > 0 and self.position_seconds >= 0:
return min((self.position_seconds / self.duration_seconds) * 100, 100.0)
return None
@property
def remaining_seconds(self) -> Optional[int]:
"""
Calculate remaining watch time in seconds.
Returns:
0 if completed; remaining seconds if duration is known; None otherwise.
"""
if self.is_completed:
return 0
if self.duration_seconds and self.duration_seconds > 0 and self.position_seconds >= 0:
return max(0, self.duration_seconds - self.position_seconds)
return None
def is_stale(self, max_age_hours: int = 720) -> bool:
"""
Check if bookmark is stale (older than max_age_hours).
Args:
max_age_hours: Maximum age in hours before bookmark is considered
stale. Default 720 hours = 30 days.
Returns:
True if bookmark hasn't been updated in the specified period.
"""
age = datetime.now() - self.last_updated
return age.total_seconds() > (max_age_hours * 3600)
@property
def composite_id(self) -> str:
"""Return a composite identifier combining provider and content_id."""
return f"{self.provider}:{self.content_id}"
# ==========================================================================
# Factory methods
# ==========================================================================
@classmethod
def create(
cls,
provider: str,
content_id: str,
content_type: ContentType,
position_seconds: int = 0,
duration_seconds: Optional[int] = None,
title: Optional[str] = None,
**kwargs,
) -> "Bookmark":
"""
Create a new bookmark with automatic ID generation.
Args:
provider: Provider name.
content_id: Content identifier.
content_type: Type of content (see ContentType).
position_seconds: Playback position in seconds.
duration_seconds: Total duration in seconds.
title: Content title (cached for UI).
**kwargs: Additional metadata (thumbnail_url, series_title, etc.).
Returns:
New Bookmark instance.
"""
now = datetime.now()
bookmark_id = f"{provider}:{content_id}"
return cls(
bookmark_id=bookmark_id,
provider=provider,
content_id=content_id,
content_type=content_type,
position_seconds=position_seconds,
duration_seconds=duration_seconds,
title=title,
last_updated=now,
created_at=now,
**kwargs,
)
@classmethod
def create_completed(
cls,
provider: str,
content_id: str,
content_type: ContentType,
duration_seconds: Optional[int] = None,
title: Optional[str] = None,
**kwargs,
) -> "Bookmark":
"""
Create a bookmark marking content as completed.
Args:
provider: Provider name.
content_id: Content identifier.
content_type: Type of content.
duration_seconds: Total duration in seconds.
title: Content title.
**kwargs: Additional metadata.
Returns:
Bookmark with position set to -1 (completed).
"""
return cls.create(
provider=provider,
content_id=content_id,
content_type=content_type,
position_seconds=-1,
duration_seconds=duration_seconds,
title=title,
**kwargs,
)
@classmethod
def from_api_dict(cls, data: Dict) -> "Bookmark":
"""
Create a Bookmark from a PascalCase API dictionary (inverse of ``to_dict``).
Args:
data: Dictionary with PascalCase keys as returned by ``to_dict()``.
Returns:
Bookmark instance.
"""
def _parse_dt(value) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(value)
return cls(
bookmark_id=data["BookmarkId"],
provider=data["Provider"],
content_id=data["ContentId"],
content_type=ContentType(data["ContentType"]),
position_seconds=data.get("PositionSeconds", 0),
duration_seconds=data.get("DurationSeconds"),
last_updated=_parse_dt(data.get("LastUpdated")) or datetime.now(),
created_at=_parse_dt(data.get("CreatedAt")) or datetime.now(),
title=data.get("Title"),
thumbnail_url=data.get("ThumbnailUrl"),
series_title=data.get("SeriesTitle"),
season_number=data.get("SeasonNumber"),
episode_number=data.get("EpisodeNumber"),
episode_name=data.get("EpisodeName"),
channel_name=data.get("ChannelName"),
channel_logo=data.get("ChannelLogo"),
)
@classmethod
def from_storage_dict(cls, data: Dict) -> "Bookmark":
"""
Create a Bookmark from a snake_case storage dictionary
(inverse of ``to_storage_dict``).
Args:
data: Dictionary with snake_case keys as written by
``to_storage_dict()``.
Returns:
Bookmark instance.
"""
def _parse_dt(value) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(value)
return cls(
bookmark_id=data["bookmark_id"],
provider=data["provider"],
content_id=data["content_id"],
content_type=ContentType(data["content_type"]),
position_seconds=data.get("position_seconds", 0),
duration_seconds=data.get("duration_seconds"),
last_updated=_parse_dt(data.get("last_updated")) or datetime.now(),
created_at=_parse_dt(data.get("created_at")) or datetime.now(),
title=data.get("title"),
)
# ==========================================================================
# Serialization
# ==========================================================================
def to_dict(self, include_none: bool = True) -> Dict:
"""
Convert bookmark to a PascalCase dictionary for API responses.
Args:
include_none: When True (default) all fields are present so callers
can distinguish "field is absent" from "field is None".
Pass False for a compact payload that omits None values.
Returns:
Dictionary with PascalCase keys.
"""
result = {
# Core identification
"BookmarkId": self.bookmark_id,
"Provider": self.provider,
"ContentId": self.content_id,
"ContentType": self.content_type.value,
# Playback position
"PositionSeconds": self.position_seconds,
"DurationSeconds": self.duration_seconds,
"ProgressPercent": self.progress_percent,
"RemainingSeconds": self.remaining_seconds,
"IsCompleted": self.is_completed,
# Timestamps
"LastUpdated": self.last_updated.isoformat(),
"CreatedAt": self.created_at.isoformat(),
# Cached metadata
"Title": self.title,
"ThumbnailUrl": self.thumbnail_url,
# Series context
"SeriesTitle": self.series_title,
"SeasonNumber": self.season_number,
"EpisodeNumber": self.episode_number,
"EpisodeName": self.episode_name,
# Channel context
"ChannelName": self.channel_name,
"ChannelLogo": self.channel_logo,
}
if not include_none:
return {k: v for k, v in result.items() if v is not None}
return result
def to_storage_dict(self) -> Dict:
"""
Convert to a snake_case dictionary for storage backends (minimal fields).
Metadata fields (thumbnail, series info, channel info) are intentionally
omitted — they are expected to be re-fetched from the provider on restore
so that cached data does not become stale across storage roundtrips.
Returns:
Dictionary with only essential fields.
"""
return {
"bookmark_id": self.bookmark_id,
"provider": self.provider,
"content_id": self.content_id,
"content_type": self.content_type.value,
"position_seconds": self.position_seconds,
"duration_seconds": self.duration_seconds,
"last_updated": self.last_updated.isoformat(),
"created_at": self.created_at.isoformat(),
"title": self.title,
}
# ==========================================================================
# Validation
# ==========================================================================
def validate(self) -> List[Tuple[ValidationLevel, str]]:
"""
Validate bookmark data integrity.
Returns:
List of (ValidationLevel, message) tuples. Empty list means valid.
ERROR entries indicate the bookmark is unusable without a fix.
WARNING entries indicate unexpected but non-fatal conditions.
"""
issues: List[Tuple[ValidationLevel, str]] = []
if not self.provider:
issues.append((ValidationLevel.ERROR, "Provider name is required"))
if not self.content_id:
issues.append((ValidationLevel.ERROR, "Content ID is required"))
if self.position_seconds < -1:
issues.append((
ValidationLevel.ERROR,
f"Invalid position_seconds: {self.position_seconds} (must be >= -1)",
))
if self.duration_seconds is not None and self.duration_seconds <= 0:
issues.append((
ValidationLevel.ERROR,
f"duration_seconds must be positive, got {self.duration_seconds}",
))
if (
self.position_seconds > 0
and self.duration_seconds
and self.position_seconds > self.duration_seconds
):
issues.append((
ValidationLevel.WARNING,
f"Position ({self.position_seconds}s) exceeds duration ({self.duration_seconds}s)",
))
if self.season_number is not None and self.season_number < 0:
issues.append((
ValidationLevel.WARNING,
f"season_number must be >= 0, got {self.season_number}",
))
if self.episode_number is not None and self.episode_number < 0:
issues.append((
ValidationLevel.WARNING,
f"episode_number must be >= 0, got {self.episode_number}",
))
return issues
def is_valid(self) -> bool:
"""Return True if the bookmark has no ERROR-level validation issues."""
return not any(level == ValidationLevel.ERROR for level, _ in self.validate())
# ==========================================================================
# Comparison
# ==========================================================================
def __eq__(self, other) -> bool:
"""Two bookmarks are equal if they point to the same content."""
if not isinstance(other, Bookmark):
return False
return self.provider == other.provider and self.content_id == other.content_id
def __hash__(self) -> int:
"""Hash based on provider and content_id."""
return hash((self.provider, self.content_id))