From d2fcc3173956378b3ba076ea6f4fae0001bccc36 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 12 Aug 2026 09:45:25 +0200 Subject: [PATCH] update models --- .../base/models/channel.py | 3 +- .../base/models/content.py | 125 ++++++++++++-- .../base/models/pricing.py | 159 ++++++++++++++++++ .../base/models/quality.py | 16 ++ .../base/models/subscription.py | 126 ++++++-------- lib/streaming_providers/base/models/vod.py | 53 +++--- 6 files changed, 370 insertions(+), 112 deletions(-) create mode 100644 lib/streaming_providers/base/models/pricing.py create mode 100644 lib/streaming_providers/base/models/quality.py diff --git a/lib/streaming_providers/base/models/channel.py b/lib/streaming_providers/base/models/channel.py index 2502f70..ec9761a 100644 --- a/lib/streaming_providers/base/models/channel.py +++ b/lib/streaming_providers/base/models/channel.py @@ -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 diff --git a/lib/streaming_providers/base/models/content.py b/lib/streaming_providers/base/models/content.py index 45df10b..35265db 100644 --- a/lib/streaming_providers/base/models/content.py +++ b/lib/streaming_providers/base/models/content.py @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/base/models/pricing.py b/lib/streaming_providers/base/models/pricing.py new file mode 100644 index 0000000..56d7ba4 --- /dev/null +++ b/lib/streaming_providers/base/models/pricing.py @@ -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, + } \ No newline at end of file diff --git a/lib/streaming_providers/base/models/quality.py b/lib/streaming_providers/base/models/quality.py new file mode 100644 index 0000000..cb45ad2 --- /dev/null +++ b/lib/streaming_providers/base/models/quality.py @@ -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" \ No newline at end of file diff --git a/lib/streaming_providers/base/models/subscription.py b/lib/streaming_providers/base/models/subscription.py index 4cad8ec..2c0762b 100644 --- a/lib/streaming_providers/base/models/subscription.py +++ b/lib/streaming_providers/base/models/subscription.py @@ -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 + } \ No newline at end of file diff --git a/lib/streaming_providers/base/models/vod.py b/lib/streaming_providers/base/models/vod.py index c45c38f..574a740 100644 --- a/lib/streaming_providers/base/models/vod.py +++ b/lib/streaming_providers/base/models/vod.py @@ -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,