mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 14:12:20 +02:00
232 lines
8.8 KiB
Python
232 lines
8.8 KiB
Python
# streaming_providers/base/models/pricing.py
|
|
from decimal import Decimal
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import List, Optional, Dict
|
|
from .quality import Quality
|
|
|
|
|
|
class AccessType(Enum):
|
|
"""How content can be accessed monetarily."""
|
|
FREE = "free" # No payment, no ads
|
|
AVOD = "avod" # Ad-supported free
|
|
SVOD = "svod" # Subscription included
|
|
TVOD_RENTAL = "tvod_rental" # Time-limited rental
|
|
TVOD_PURCHASE = "tvod_purchase" # Buy-to-own (EST)
|
|
PPV_LIVE = "ppv_live" # Pay-per-view live event
|
|
PPV_REPLAY = "ppv_replay" # Pay-per-view replay window
|
|
SVOD_PPV = "svod_ppv" # Subscription + extra PPV surcharge
|
|
|
|
|
|
@dataclass
|
|
class PricePoint:
|
|
"""A specific price for a region/quality/time period."""
|
|
amount: Decimal
|
|
currency: str # ISO 4217
|
|
# Disambiguates buy vs rent when a single Pricing holds both offers in
|
|
# parallel (e.g. Content available to both rent and purchase from the
|
|
# same partner). None = this point follows the parent Pricing.access_type.
|
|
offer_type: Optional[AccessType] = None
|
|
# Per-offer rental window override. Only meaningful when offer_type (or
|
|
# the parent access_type) is TVOD_RENTAL. Falls back to
|
|
# Pricing.rental_duration_hours when None.
|
|
rental_duration_hours: Optional[int] = None
|
|
sku: Optional[str] = None # Billing system ID
|
|
quality: Optional[Quality] = None # "SD", "HD", "4K"
|
|
region: Optional[str] = None # "DE", "AT", "CH", etc.
|
|
valid_from: Optional[datetime] = None
|
|
valid_until: Optional[datetime] = None
|
|
tax_inclusive: bool = True # EU requirement
|
|
|
|
def is_active(self, at: Optional[datetime] = None) -> bool:
|
|
"""Check if this price point is currently valid."""
|
|
now = at or datetime.now()
|
|
if self.valid_from and now < self.valid_from:
|
|
return False
|
|
if self.valid_until and now > self.valid_until:
|
|
return False
|
|
return True
|
|
|
|
def effective_offer_type(self, parent_access_type: AccessType) -> AccessType:
|
|
"""The offer type this point actually represents."""
|
|
return self.offer_type or parent_access_type
|
|
|
|
def effective_rental_duration_hours(self, parent_rental_duration_hours: Optional[int]) -> Optional[int]:
|
|
"""
|
|
The rental duration this point actually represents.
|
|
|
|
Returns:
|
|
The per-offer rental_duration_hours if set, otherwise the parent
|
|
Pricing.rental_duration_hours. Returns None if neither is set,
|
|
which typically means the rental duration is unknown or unlimited.
|
|
"""
|
|
return self.rental_duration_hours if self.rental_duration_hours is not None else parent_rental_duration_hours
|
|
|
|
|
|
@dataclass
|
|
class Pricing:
|
|
"""
|
|
Monetization model for content.
|
|
|
|
None/unknown pricing should never be treated as free - this is a revenue-leak
|
|
risk. Always handle unknown explicitly in entitlement checks.
|
|
"""
|
|
|
|
# Access model
|
|
access_type: AccessType # No default - must be explicit
|
|
|
|
# Price points (empty for FREE/AVOD/SVOD)
|
|
price_points: List[PricePoint] = field(default_factory=list)
|
|
|
|
# Subscription gates (ANY grants access)
|
|
required_tiers: List[str] = field(default_factory=list) # "premium", "basic"
|
|
required_bouquets: List[str] = field(default_factory=list) # "sports", "movies"
|
|
|
|
# Time windows (hours)
|
|
rental_duration_hours: Optional[int] = None # TVOD_RENTAL
|
|
catchup_duration_hours: Optional[int] = None # Linear catch-up feature
|
|
replay_window_hours: Optional[int] = None # PPV event replay
|
|
preview_minutes: Optional[int] = None # Free preview
|
|
|
|
# SVOD_PPV specific
|
|
ppv_is_surcharge: bool = True # True = extra on top of subscription
|
|
|
|
# Metadata
|
|
description: Optional[str] = None # UI display text
|
|
tax_class: Optional[str] = None # VAT rate group
|
|
|
|
# --- Derived predicates ---
|
|
|
|
@property
|
|
def is_free_at_point_of_use(self) -> bool:
|
|
"""User pays nothing at consumption time."""
|
|
return self.access_type in (AccessType.FREE, AccessType.AVOD)
|
|
|
|
@property
|
|
def requires_subscription(self) -> bool:
|
|
"""User must have an active subscription."""
|
|
return self.access_type in (AccessType.SVOD, AccessType.SVOD_PPV)
|
|
|
|
@property
|
|
def requires_transactional_payment(self) -> bool:
|
|
"""User must make a one-time payment."""
|
|
return self.access_type in (
|
|
AccessType.TVOD_RENTAL,
|
|
AccessType.TVOD_PURCHASE,
|
|
AccessType.PPV_LIVE,
|
|
AccessType.PPV_REPLAY,
|
|
AccessType.SVOD_PPV,
|
|
)
|
|
|
|
@property
|
|
def is_ad_supported(self) -> bool:
|
|
"""Content includes ads (FAST/AVOD)."""
|
|
return self.access_type == AccessType.AVOD
|
|
|
|
@property
|
|
def has_time_limit(self) -> bool:
|
|
"""Access expires after a fixed time."""
|
|
return (
|
|
self.access_type == AccessType.TVOD_RENTAL
|
|
or self.replay_window_hours is not None
|
|
)
|
|
|
|
@property
|
|
def primary_price(self) -> Optional[PricePoint]:
|
|
"""
|
|
First active price point, regardless of offer type. Only meaningful
|
|
when this Pricing has a single offer type. For mixed rent/buy
|
|
pricing, use primary_rental_price / primary_purchase_price instead.
|
|
"""
|
|
active = [p for p in self.price_points if p.is_active()]
|
|
return active[0] if active else None
|
|
|
|
# --- Parallel offer accessors (rent + buy on the same Pricing) ---
|
|
|
|
def price_points_for(self, offer_type: AccessType) -> List[PricePoint]:
|
|
"""All active price points matching a specific offer type."""
|
|
return [
|
|
p for p in self.price_points
|
|
if p.is_active() and p.effective_offer_type(self.access_type) == offer_type
|
|
]
|
|
|
|
@property
|
|
def rental_price_points(self) -> List[PricePoint]:
|
|
return self.price_points_for(AccessType.TVOD_RENTAL)
|
|
|
|
@property
|
|
def purchase_price_points(self) -> List[PricePoint]:
|
|
return self.price_points_for(AccessType.TVOD_PURCHASE)
|
|
|
|
@property
|
|
def primary_rental_price(self) -> Optional[PricePoint]:
|
|
points = self.rental_price_points
|
|
return points[0] if points else None
|
|
|
|
@property
|
|
def primary_purchase_price(self) -> Optional[PricePoint]:
|
|
points = self.purchase_price_points
|
|
return points[0] if points else None
|
|
|
|
@property
|
|
def can_rent(self) -> bool:
|
|
return bool(self.rental_price_points)
|
|
|
|
@property
|
|
def can_purchase(self) -> bool:
|
|
return bool(self.purchase_price_points)
|
|
|
|
def get_price_for_region(
|
|
self, region: str, quality: Optional[str] = None,
|
|
offer_type: Optional[AccessType] = None,
|
|
) -> Optional[PricePoint]:
|
|
"""Get best matching price point for region/quality/offer type."""
|
|
matches = [
|
|
p for p in self.price_points
|
|
if p.is_active() and p.region == region
|
|
]
|
|
if quality:
|
|
matches = [p for p in matches if p.quality == quality]
|
|
if offer_type:
|
|
matches = [
|
|
p for p in matches
|
|
if p.effective_offer_type(self.access_type) == offer_type
|
|
]
|
|
return matches[0] if matches else None
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {
|
|
"access_type": self.access_type.value,
|
|
"price_points": [
|
|
{
|
|
"amount": str(p.amount),
|
|
"currency": p.currency,
|
|
"offer_type": p.offer_type.value if p.offer_type else None,
|
|
"rental_duration_hours": p.rental_duration_hours,
|
|
"sku": p.sku,
|
|
"quality_label": p.quality,
|
|
"region": p.region,
|
|
"valid_from": p.valid_from.isoformat() if p.valid_from else None,
|
|
"valid_until": p.valid_until.isoformat() if p.valid_until else None,
|
|
"tax_inclusive": p.tax_inclusive,
|
|
}
|
|
for p in self.price_points
|
|
],
|
|
"required_tiers": self.required_tiers,
|
|
"required_bouquets": self.required_bouquets,
|
|
"rental_duration_hours": self.rental_duration_hours,
|
|
"catchup_duration_hours": self.catchup_duration_hours,
|
|
"replay_window_hours": self.replay_window_hours,
|
|
"preview_minutes": self.preview_minutes,
|
|
"ppv_is_surcharge": self.ppv_is_surcharge,
|
|
"description": self.description,
|
|
"tax_class": self.tax_class,
|
|
"can_rent": self.can_rent,
|
|
"can_purchase": self.can_purchase,
|
|
"is_free_at_point_of_use": self.is_free_at_point_of_use,
|
|
"requires_subscription": self.requires_subscription,
|
|
"requires_transactional_payment": self.requires_transactional_payment,
|
|
"is_ad_supported": self.is_ad_supported,
|
|
"has_time_limit": self.has_time_limit,
|
|
} |