Files
script.service.ultimate/lib/streaming_providers/base/models/content.py
T
2026-10-18 09:14:16 +02:00

285 lines
9.8 KiB
Python

import logging
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict, List, Optional
from ..stream_format import StreamFormat
from .drm import DRMConfig
from .pricing import AccessType, PricePoint, Pricing
# stdlib logger on purpose: this module must not import utils.* (cycle risk).
_log = logging.getLogger(__name__)
class StreamingMode:
"""Enum for streaming modes"""
LIVE = "live"
VOD = "vod"
class ContentType:
"""Enum for content types"""
LIVE = "LIVE"
VOD = "VOD"
SERIES = "SERIES"
MOVIE = "MOVIE"
RADIO = "RADIO"
@dataclass
class Content:
"""
Base dataclass for all provider content (channels, events, etc.)
Contains all fields shared between content types.
"""
# Core identification
name: str
content_id: str
provider: str
# Visual
logo_url: Optional[str] = None
# Streaming configuration
mode: str = "live"
session_manifest: bool = False
manifest: Optional[str] = None
manifest_script: Optional[str] = None
# DRM/CDM settings
cdm_type: Optional[str] = None
use_cdm: bool = True
cdm: Optional[str] = None
cdm_mode: str = "external"
drm_config: Optional[DRMConfig] = None
# Video settings
video: str = "best"
on_demand: bool = True
speed_up: bool = True
# Metadata
quality: Optional[str] = None
content_type: str = "LIVE"
description: Optional[str] = None
genre: Optional[str] = None
language: str = "de"
country: str = "DE"
# Streaming URLs
license_url: Optional[str] = None
certificate_url: Optional[str] = None
# Format HINT (None = unknown). Normalised to StreamFormat in
# __post_init__; the authoritative value is the one detected from the
# manifest during DRM/format resolution. Dynamic-manifest items cannot
# know it at list time, so None is common and valid.
streaming_format: Optional[StreamFormat] = None
# Pricing (None = UNKNOWN, NOT FREE)
pricing: Optional[Pricing] = None
def _pricing_mode_problems(self) -> List[str]:
"""
Pricing-vs-mode inconsistencies, as human-readable strings.
Only combinations that are unambiguous are constrained:
PPV_LIVE -> mode must be "live"
TVOD_RENTAL / TVOD_PURCHASE -> mode must be "vod"
AVOD, PPV_REPLAY and SVOD_PPV used to be forced to "live". That made
every ad-supported on-demand item and every PPV replay (the VodItem
docstring's own "past sports event") fail at construction, because
VodItem normalises mode to "vod" before validating. Those access
types describe how content is MONETISED, not how it is delivered, so
they no longer constrain the mode.
"""
if not self.pricing:
return []
access = self.pricing.access_type
problems: List[str] = []
if access == AccessType.PPV_LIVE and self.mode != StreamingMode.LIVE:
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.LIVE}')"
)
elif (
access in (AccessType.TVOD_RENTAL, AccessType.TVOD_PURCHASE)
and self.mode != StreamingMode.VOD
):
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.VOD}')"
)
return problems
def _normalize_streaming_format(self) -> None:
"""Coerce a legacy/provider-supplied string to StreamFormat (or None).
A helper rather than inline __post_init__ code because Channel
deliberately does NOT call Content.__post_init__ (see
Channel._validate_fields); it calls this explicitly instead.
"""
raw = self.streaming_format
if raw is None or isinstance(raw, StreamFormat):
return
parsed = StreamFormat.parse(raw)
if parsed is None and str(raw).strip():
_log.debug(
"Content %s/%s: unrecognised streaming_format %r treated as unknown",
self.provider, self.content_id, raw,
)
self.streaming_format = parsed
def __post_init__(self):
"""Validate pricing and mode consistency (strict: raises)."""
self._normalize_streaming_format()
problems = self._pricing_mode_problems()
if problems:
raise ValueError(problems[0])
# --- Pricing Properties ---
@property
def is_free(self) -> Optional[bool]:
"""Returns None if pricing is unknown."""
if not self.pricing:
return None
return self.pricing.is_free_at_point_of_use
@property
def requires_subscription(self) -> Optional[bool]:
if not self.pricing:
return None
return self.pricing.requires_subscription
@property
def requires_payment(self) -> Optional[bool]:
if not self.pricing:
return None
return self.pricing.requires_transactional_payment
@property
def has_pricing(self) -> bool:
"""Check if pricing is set."""
return self.pricing is not None
# --- Pricing Helpers ---
def set_free(self) -> None:
"""Mark content as free."""
self.pricing = Pricing(access_type=AccessType.FREE)
def set_subscription(self, tiers: Optional[List[str]] = None, bouquets: Optional[List[str]] = None) -> None:
"""Mark content as subscription-only."""
self.pricing = Pricing(
access_type=AccessType.SVOD,
required_tiers=tiers or [],
required_bouquets=bouquets or []
)
def set_ppv(self, amount: Decimal, currency: str = "EUR",
access_type: AccessType = AccessType.PPV_LIVE, **kwargs) -> None:
"""Mark content as PPV (live or replay)."""
if access_type not in (AccessType.PPV_LIVE, AccessType.PPV_REPLAY):
raise ValueError("access_type must be PPV_LIVE or PPV_REPLAY")
self.pricing = Pricing(
access_type=access_type,
price_points=[PricePoint(amount=amount, currency=currency, **kwargs)]
)
def set_rental(self, amount: Decimal, currency: str = "EUR",
rental_duration_hours: int = 48, **kwargs) -> None:
"""Mark content as TVOD rental."""
self.pricing = Pricing(
access_type=AccessType.TVOD_RENTAL,
rental_duration_hours=rental_duration_hours,
price_points=[PricePoint(amount=amount, currency=currency, **kwargs)]
)
def set_purchase(self, amount: Decimal, currency: str = "EUR", **kwargs) -> None:
"""Mark content as EST purchase (buy-to-own)."""
self.pricing = Pricing(
access_type=AccessType.TVOD_PURCHASE,
price_points=[PricePoint(amount=amount, currency=currency, **kwargs)]
)
# --- Manifest & DRM Methods ---
def set_static_manifest(self, manifest_url: str) -> None:
"""Set a static manifest URL."""
self.manifest = manifest_url
self.session_manifest = False
self.manifest_script = None
def set_dynamic_manifest(self, manifest_script_params: str) -> None:
"""Set dynamic manifest parameters fetched at request time."""
self.manifest = None
self.session_manifest = True
self.manifest_script = manifest_script_params
def get_streaming_urls(self) -> List[str]:
"""Return all relevant URLs."""
urls = []
if self.manifest:
urls.append(self.manifest)
if self.license_url:
urls.append(self.license_url)
if self.certificate_url:
urls.append(self.certificate_url)
return urls
def requires_drm(self) -> bool:
return bool(self.drm_config) or bool(self.license_url)
@property
def dynamic_manifest(self) -> bool:
return self.session_manifest
@dynamic_manifest.setter
def dynamic_manifest(self, value: bool):
self.session_manifest = value
@property
def requires_session_manifest(self) -> bool:
return self.session_manifest
# --- Serialization ---
def to_dict(self) -> Dict:
# parse() again (not just .value): streaming_format may have been
# assigned a plain string after construction.
stream_format = StreamFormat.parse(self.streaming_format)
result = {
"Name": self.name,
"Id": self.content_id,
"Provider": self.provider,
"LogoUrl": self.logo_url,
"Quality": self.quality,
"Mode": self.mode,
"SessionManifest": self.session_manifest,
"Manifest": self.manifest,
"ManifestScript": self.manifest_script,
"CdmType": self.cdm_type,
"UseCdm": self.use_cdm,
"Cdm": self.cdm,
"CdmMode": self.cdm_mode,
"Video": self.video,
"OnDemand": self.on_demand,
"SpeedUp": self.speed_up,
"ContentType": self.content_type,
"Country": self.country,
"Language": self.language,
"Description": self.description,
"Genre": self.genre,
"StreamingFormat": stream_format.value if stream_format else None,
"LicenseUrl": self.license_url,
"CertificateUrl": self.certificate_url,
# Pricing derived fields
"IsFree": self.is_free,
"RequiresSubscription": self.requires_subscription,
"RequiresPayment": self.requires_payment,
"HasPricing": self.has_pricing,
}
if self.drm_config:
result["DrmConfig"] = self.drm_config.to_dict()
if self.pricing:
result["Pricing"] = self.pricing.to_dict()
return result