# streaming_providers/providers/discovery/models.py """ Discovery+ Provider Models Data models for Discovery+ channels and content. """ import json from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, Optional if TYPE_CHECKING: from ...base.models import StreamingChannel, DRMConfig, LicenseConfig from .constants import StreamingMode, CDMMode, VideoQuality, DRMSystem @dataclass class DiscoveryChannel: """ Represents a Discovery+ channel/stream with all necessary streaming data. This is a Discovery-specific model that can be converted to the generic StreamingChannel model via to_streaming_channel(). """ # Core identification name: str channel_id: str # distributionChannel ID (first UUID) edit_id: Optional[str] = None # edit ID (second UUID) - used for playback # Visual elements logo_url: Optional[str] = None # Channel metadata channel_number: Optional[int] = None quality: Optional[str] = None description: Optional[str] = None # Streaming configuration mode: str = StreamingMode.LIVE.value session_manifest: bool = True manifest: Optional[str] = None manifest_script: Optional[str] = None # CDM settings cdm_type: Optional[str] = None use_cdm: bool = True cdm: Optional[str] = None cdm_mode: str = CDMMode.EXTERNAL.value # Video settings video: str = VideoQuality.BEST.value on_demand: bool = True speed_up: bool = False # Additional metadata content_type: str = "LIVE" genre: Optional[str] = None language: str = "de" country: str = "DE" # Streaming data license_url: Optional[str] = None certificate_url: Optional[str] = None streaming_format: Optional[str] = None # Event data (for events/VOD) start_time: Optional[int] = None end_time: Optional[int] = None # Internal tracking - provider-specific data raw_data: Dict[str, Any] = field(default_factory=dict) @classmethod def from_distribution_data( cls, distribution_data: Dict[str, Any], included_by_id: Dict[str, Any] ) -> "DiscoveryChannel": """ Create DiscoveryChannel from distributionChannel API response. Args: distribution_data: The channel data from API response.data included_by_id: Map of included resources keyed by "type:id" for resolving relationships (images, edits, etc.) Returns: Initialized DiscoveryChannel with metadata and relationships resolved Example: The included_by_id dictionary should be built from the 'included' array in the API response using: included_by_id = {} for item in response.get('included', []): key = f"{item['type']}:{item['id']}" included_by_id[key] = item Then create a channel from the first distribution data item: channel = DiscoveryChannel.from_distribution_data( response['data'][0], included_by_id ) print(channel.name) # Output: Eurosport 1 """ attributes = distribution_data.get("attributes", {}) relationships = distribution_data.get("relationships", {}) # edit_id is the playback identifier — used as content_id throughout edit_data = relationships.get("edit", {}).get("data", {}) edit_id = edit_data.get("id") # distribution_id is the CMS channel UUID — kept as metadata only distribution_id = distribution_data.get("id", "") # Extract logo logo_url = cls._extract_logo_url(relationships, included_by_id) raw = distribution_data.copy() raw["distribution_id"] = distribution_id return cls( name=attributes.get("name", "Unknown Channel"), channel_id=edit_id or distribution_id, # edit_id is the primary key edit_id=edit_id, logo_url=logo_url, description=attributes.get("description", ""), mode=StreamingMode.LIVE.value, session_manifest=True, manifest_script=distribution_id or None, cdm=distribution_id or None, raw_data=raw, ) @classmethod def from_event_api_data( cls, api_data: Dict[str, Any], included_by_id: Dict[str, Any], **kwargs: Any ) -> "DiscoveryChannel": """ Create DiscoveryChannel from event/VOD API response data. Args: api_data: Event/VOD data from API response included_by_id: Map of included resources **kwargs: Additional parameters to override defaults Returns: Initialized DiscoveryChannel for VOD content """ attributes = api_data.get("attributes", {}) relationships = api_data.get("relationships", {}) # For VOD/events, the item ID itself is the edit_id (playback identifier) edit_id = api_data.get("id") # Extract logo logo_url = cls._extract_logo_url(relationships, included_by_id) raw = api_data.copy() raw["source_id"] = edit_id # preserve original CMS ID as metadata channel = cls( name=attributes.get("name", "Unknown Event"), channel_id=edit_id or "", # edit_id is the primary key edit_id=edit_id, logo_url=logo_url, content_type="VOD", mode=StreamingMode.VOD.value, session_manifest=True, manifest_script=edit_id or None, cdm=edit_id or None, raw_data=raw, **kwargs ) # Set event timestamps if available if "scheduleStart" in attributes: channel.start_time = cls._parse_timestamp(attributes["scheduleStart"]) if "scheduleEnd" in attributes: channel.end_time = cls._parse_timestamp(attributes["scheduleEnd"]) return channel @staticmethod def _extract_logo_url( relationships: Dict[str, Any], included_by_id: Dict[str, Any] ) -> Optional[str]: """ Extract logo URL from relationship data. Args: relationships: Relationship data from API response included_by_id: Map of included resources Returns: Logo URL or None if not found """ images_data = relationships.get("images", {}).get("data", []) for image_ref in images_data: image_id = image_ref.get("id") image_type = image_ref.get("type") if image_id and image_type: key = f"{image_type}:{image_id}" image_item = included_by_id.get(key) if image_item: img_attrs = image_item.get("attributes", {}) logo_url = ( img_attrs.get("src") or img_attrs.get("url") or img_attrs.get("secureUrl") ) if logo_url: return logo_url return None @staticmethod def _parse_timestamp(timestamp_str: str) -> Optional[int]: """ Parse ISO 8601 timestamp to Unix timestamp. Args: timestamp_str: ISO 8601 timestamp string (e.g., "2024-01-15T20:00:00Z") Returns: Unix timestamp (seconds since epoch) or None if parsing fails """ try: # Handle both Z and +00:00 formats dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) return int(dt.timestamp()) except (ValueError, TypeError, AttributeError): return None def set_streaming_data( self, manifest_url: str, license_url: Optional[str] = None, drm_token: Optional[str] = None, streaming_format: str = "dash", ) -> None: """ Configure streaming-specific data. Args: manifest_url: Manifest URL for streaming license_url: License URL for DRM drm_token: DRM token for authorization streaming_format: Streaming format (e.g., 'dash') """ self.manifest = manifest_url self.streaming_format = streaming_format if license_url: self.license_url = license_url self.cdm_type = DRMSystem.WIDEVINE.value if drm_token: self.raw_data["drm_token"] = drm_token def to_streaming_channel(self, provider_name: str = "discovery") -> "StreamingChannel": """ Convert to generic StreamingChannel object. Args: provider_name: Provider identifier Returns: Generic StreamingChannel instance with all data transferred """ from ...base.models import StreamingChannel, DRMConfig # Create DRM config if we have license URL drm_config = None if self.license_url: headers = {} if "drm_token" in self.raw_data: headers["Authorization"] = f"Bearer {self.raw_data['drm_token']}" drm_config = DRMConfig( system=DRMSystem.WIDEVINE, # ✅ Use system field priority=1, license=LicenseConfig( server_url=self.license_url, req_headers=json.dumps(headers), req_data="{CHA-RAW}", use_http_get_request=False, ), ) channel = StreamingChannel( name=self.name, content_id=self.channel_id, provider=provider_name, logo_url=self.logo_url, mode=self.mode, session_manifest=self.session_manifest, manifest=self.manifest, manifest_script=self.manifest_script, cdm_type=self.cdm_type, use_cdm=self.use_cdm, cdm=self.cdm, cdm_mode=self.cdm_mode, video=self.video, on_demand=self.on_demand, speed_up=self.speed_up, content_type=self.content_type, description=self.description, genre=self.genre, language=self.language, country=self.country, license_url=self.license_url, certificate_url=self.certificate_url, streaming_format=self.streaming_format, drm_config=drm_config, ) # Transfer raw_data channel.raw_data = self.raw_data.copy() # Store distribution_id in raw_data for reference (channels only) if "distribution_id" in self.raw_data: channel.raw_data["distribution_id"] = self.raw_data["distribution_id"] return channel def to_dict(self) -> Dict[str, Any]: """ Convert to dictionary format. Returns: Dictionary representation of the channel """ return { "Name": self.name, "Id": self.channel_id, "EditId": self.edit_id, "LogoUrl": self.logo_url, "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, "Description": self.description, "Language": self.language, "Country": self.country, "StreamingFormat": self.streaming_format, } def __str__(self) -> str: return ( f"DiscoveryChannel(name='{self.name}', id='{self.channel_id}', " f"edit_id='{self.edit_id}', type='{self.content_type}')" ) def __repr__(self) -> str: return self.__str__()