update models

This commit is contained in:
Nirvana
2026-08-12 09:45:25 +02:00
parent 752f2e8cdb
commit d2fcc31739
6 changed files with 370 additions and 112 deletions
@@ -2,7 +2,8 @@
from dataclasses import dataclass
from typing import Dict, List, Optional
from .content import Content, Quality
from .content import Content
from .quality import Quality
from ..utils.logger import logger
+112 -13
View File
@@ -1,9 +1,9 @@
# streaming_providers/base/models/content.py
from dataclasses import dataclass
from typing import Dict, List, Optional
from decimal import Decimal
from typing import Dict, List, Optional, Set
from .drm import DRMConfig
from .pricing import AccessType, PricePoint, Pricing
class StreamingMode:
"""Enum for streaming modes"""
@@ -19,16 +19,6 @@ class ContentType:
MOVIE = "MOVIE"
RADIO = "RADIO"
class Quality:
"""Enum for stream quality"""
SD = "SD"
HD = "HD"
UHD = "UHD"
FOUR_K = "4K"
AUDIO = "AUDIO"
@dataclass
class Content:
"""
@@ -75,6 +65,106 @@ class Content:
certificate_url: Optional[str] = None
streaming_format: Optional[str] = None
# Pricing (None = UNKNOWN, NOT FREE)
pricing: Optional[Pricing] = None
def __post_init__(self):
"""Validate pricing and mode consistency."""
if not self.pricing:
return
# Define mode mappings
live_types: Set[AccessType] = {
AccessType.PPV_LIVE,
AccessType.PPV_REPLAY,
AccessType.SVOD_PPV,
AccessType.AVOD
}
vod_types: Set[AccessType] = {
AccessType.TVOD_RENTAL,
AccessType.TVOD_PURCHASE
}
# Check mode consistency
if self.pricing.access_type in live_types and self.mode != StreamingMode.LIVE:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.LIVE}')"
)
elif self.pricing.access_type in vod_types and self.mode != StreamingMode.VOD:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.VOD}')"
)
# --- 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
@@ -113,6 +203,8 @@ class Content:
def requires_session_manifest(self) -> bool:
return self.session_manifest
# --- Serialization ---
def to_dict(self) -> Dict:
result = {
"Name": self.name,
@@ -137,7 +229,14 @@ class Content:
"StreamingFormat": self.streaming_format,
"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
@@ -0,0 +1,159 @@
# 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
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
@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]:
"""Get the first active price point (simplified access)."""
active = [p for p in self.price_points if p.is_active()]
return active[0] if active else None
def get_price_for_region(self, region: str, quality: Optional[str] = None) -> Optional[PricePoint]:
"""Get best matching price point for region/quality."""
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]
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), # Decimal → str for JSON
"currency": p.currency,
"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,
# Derived
"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,
}
@@ -0,0 +1,16 @@
# streaming_providers/base/models/quality.py
from enum import Enum
class Quality(str, Enum):
"""Stream quality levels."""
SD = "SD"
HD = "HD"
UHD = "UHD"
FOUR_K = "4K"
AUDIO = "AUDIO"
class StreamingFormat(str, Enum):
"""Streaming protocol formats."""
HLS = "hls"
DASH = "dash"
MSS = "mss"
@@ -1,37 +1,31 @@
"""
Subscription models for provider packages and user entitlements.
"""
# streaming_providers/base/models/subscription.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Set
from typing import Dict, List, Optional, Set, Any # <-- Added Any
from .pricing import Pricing
@dataclass
class SubscriptionPackage:
"""Represents a subscription package with provider-specific naming"""
"""Represents a subscription package (a purchasable bouquet/tier)."""
package_id: str
"""Internal ID (e.g., 'sports_package_2024', 'joyn_plus')"""
name: str
"""Display name (e.g., 'Sports Package', 'Sky Sport', 'Joyn Plus')"""
tier: Optional[str] = None
bouquet: Optional[str] = None
pricing: Optional[Pricing] = None
description: Optional[str] = None
"""Optional description of the package"""
price_info: Optional[str] = None
"""Optional price information (e.g., '€9.99/month', 'included')"""
channel_ids: List[str] = field(default_factory=list)
"""List of channel IDs included in this package"""
metadata: Dict[str, any] = field(default_factory=dict)
"""Provider-specific metadata (e.g., {'sky_id': 'SPORT1', 'category': 'sports'})"""
# Fix is here:
metadata: Dict[str, Any] = field(default_factory=dict)
"""Provider-specific metadata"""
@property
def channel_count(self) -> int:
"""Number of channels in this package"""
return len(self.channel_ids)
@@ -40,72 +34,73 @@ class UserSubscription:
"""User's subscription status for a provider"""
provider: str
"""Provider name (e.g., 'joyn', 'magenta')"""
country: str
"""Country code (e.g., 'DE', 'AT')"""
active: bool = False
"""Whether the subscription is currently active"""
"""Whether the account subscription is currently active"""
packages: List[SubscriptionPackage] = field(default_factory=list)
"""All subscription packages the user has access to"""
accessible_channel_ids: Set[str] = field(default_factory=set)
"""Set of all channel IDs the user can access (derived from packages)"""
"""All subscription packages the user has purchased"""
valid_from: Optional[datetime] = None
"""When the subscription becomes/starts valid"""
valid_until: Optional[datetime] = None
"""When the subscription expires"""
status_message: Optional[str] = None
"""Human-readable status message (e.g., 'Active until 2024-12-31')"""
billing_status: Optional[str] = None
"""Billing status (e.g., 'paid', 'trial', 'expired', 'cancelled')"""
def __post_init__(self):
"""Populate derived fields after initialization"""
self._update_derived_fields()
def _update_derived_fields(self):
"""Update derived fields like accessible_channel_ids"""
self.accessible_channel_ids.clear()
"""Pre-calculate sets for fast entitlement checks"""
self.accessible_channel_ids: Set[str] = set()
self.active_tiers: Set[str] = set()
self.active_bouquets: Set[str] = set()
if not self.active or not self._is_currently_valid():
return
for package in self.packages:
self.accessible_channel_ids.update(package.channel_ids)
if package.tier:
self.active_tiers.add(package.tier)
if package.bouquet:
self.active_bouquets.add(package.bouquet)
@property
def has_packages(self) -> bool:
"""Check if user has any packages"""
return len(self.packages) > 0
def _is_currently_valid(self) -> bool:
"""Check if subscription is within valid date range"""
now = 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
@property
def package_count(self) -> int:
"""Number of packages"""
return len(self.packages)
# --- Entitlement Checks ---
@property
def accessible_channel_count(self) -> int:
"""Number of accessible channels"""
return len(self.accessible_channel_ids)
def has_tier(self, tier: str) -> bool:
"""Check if user has a specific tier"""
return self.active and tier in self.active_tiers
@property
def package_names(self) -> List[str]:
"""List of package names for display"""
return [pkg.name for pkg in self.packages]
def has_bouquet(self, bouquet: str) -> bool:
"""Check if user has a specific bouquet"""
return self.active and bouquet in self.active_bouquets
def can_access_channel(self, channel_id: str) -> bool:
"""Check if user can access a specific channel"""
return self.active and channel_id in self.accessible_channel_ids
# --- Serialization ---
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization"""
return {
"provider": self.provider,
"country": self.country,
"active": self.active,
"has_packages": self.has_packages,
"package_count": self.package_count,
"package_names": self.package_names,
"accessible_channel_count": self.accessible_channel_count,
"has_packages": len(self.packages) > 0,
"package_count": len(self.packages),
"accessible_channel_count": len(self.accessible_channel_ids),
"active_tiers": list(self.active_tiers),
"active_bouquets": list(self.active_bouquets),
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
"status_message": self.status_message,
@@ -114,20 +109,11 @@ class UserSubscription:
{
"package_id": pkg.package_id,
"name": pkg.name,
"description": pkg.description,
"price_info": pkg.price_info,
"tier": pkg.tier,
"bouquet": pkg.bouquet,
"pricing": pkg.pricing.to_dict() if pkg.pricing else None,
"channel_count": pkg.channel_count,
"metadata": pkg.metadata,
}
for pkg in self.packages
],
}
def add_package(self, package: SubscriptionPackage):
"""Add a package and update derived fields"""
self.packages.append(package)
self._update_derived_fields()
def can_access_channel(self, channel_id: str) -> bool:
"""Check if user can access a specific channel"""
return channel_id in self.accessible_channel_ids
}
+25 -28
View File
@@ -5,7 +5,7 @@ import unicodedata
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from .content import Content
from .content import Content, StreamingMode, ContentType
from ..utils.logger import logger
@@ -129,13 +129,10 @@ class VodCategory:
def __post_init__(self):
"""Validate and clean up required fields."""
# Defensive: ensure name is a string
if self.name is None:
logger.warning(f"VodCategory created with None name for content_id {self.content_id}")
# Defensive: ensure name is a valid string
if not isinstance(self.name, str) or not self.name.strip():
logger.warning(f"VodCategory created with invalid name for content_id {self.content_id}")
self.name = f"Unbenannt_{self.content_id}"
elif not isinstance(self.name, str):
logger.warning(f"VodCategory created with non-string name '{self.name}' for content_id {self.content_id}")
self.name = str(self.name)
# Ensure content_id is valid
if not self.content_id:
@@ -226,26 +223,26 @@ class VodItem(Content):
def __post_init__(self):
"""Validate and clean up required fields."""
# Defensive: ensure name is a string
if self.name is None:
logger.warning(f"VodItem created with None name for content_id {self.content_id}")
# Defensive: ensure name is a valid string
if not isinstance(self.name, str) or not self.name.strip():
logger.warning(f"VodItem created with invalid name for content_id {self.content_id}")
self.name = f"Unbenanntes Video_{self.content_id}"
elif not isinstance(self.name, str):
logger.warning(f"VodItem created with non-string name '{self.name}' for content_id {self.content_id}")
self.name = str(self.name)
# Ensure mode and content_type are set correctly for on-demand content
if self.mode == "live":
self.mode = "vod"
if self.content_type == "LIVE":
self.content_type = "VOD"
if self.mode == StreamingMode.LIVE:
self.mode = StreamingMode.VOD
if self.content_type == ContentType.LIVE:
self.content_type = ContentType.VOD
# Defensive: handle None for season/episode numbers
# Defensive: handle negative season/episode numbers
if self.season_number is not None and self.season_number < 0:
self.season_number = None
if self.episode_number is not None and self.episode_number < 0:
self.episode_number = None
# 🔥 CRITICAL: Call parent __post_init__ to validate Pricing vs Mode!
super().__post_init__()
@property
def slug(self) -> str:
if not self._slug:
@@ -312,32 +309,32 @@ class VodItem(Content):
# Factory methods
@classmethod
def create_movie(
cls, name: str, content_id: str, provider: str, **kwargs
cls, name: Optional[str], content_id: str, provider: str, **kwargs
) -> "VodItem":
# Defensive: ensure name is string
if name is None:
if not isinstance(name, str) or not name:
name = f"Film_{content_id}"
return cls(
name=name,
content_id=content_id,
provider=provider,
mode="vod",
content_type="MOVIE",
mode=StreamingMode.VOD,
content_type=ContentType.MOVIE,
**kwargs,
)
@classmethod
def create_episode(
cls,
name: str,
name: Optional[str],
content_id: str,
provider: str,
season_number: int,
episode_number: int,
season_number: Optional[int],
episode_number: Optional[int],
**kwargs,
) -> "VodItem":
# Defensive: ensure name is string
if name is None:
if not isinstance(name, str) or not name:
name = f"Episode_{content_id}"
# Defensive: handle negative season/episode numbers
if season_number is not None and season_number < 0:
@@ -349,8 +346,8 @@ class VodItem(Content):
name=name,
content_id=content_id,
provider=provider,
mode="vod",
content_type="SERIES",
mode=StreamingMode.VOD,
content_type=ContentType.SERIES,
season_number=season_number,
episode_number=episode_number,
**kwargs,