diff --git a/lib/streaming_providers/providers/scripts/__init__.py b/lib/streaming_providers/providers/scripts/__init__.py new file mode 100644 index 0000000..d9d688e --- /dev/null +++ b/lib/streaming_providers/providers/scripts/__init__.py @@ -0,0 +1,87 @@ +# streaming_providers/providers/scripts/__init__.py +""" +Scripts Provider + +A universal provider that executes Python scripts to provide streaming functionality. +Each script in the configured directory becomes a separate provider instance. + +Features: +- Auto-discovers all Python scripts in SCRIPTS_PROVIDERS_PATH +- Each script must support actions: channels, manifest, cdm +- Caching for channels and DRM keys +- Subprocess execution with timeout +- Proxy support passed through to scripts +""" + +import os +import re +from typing import Optional, List, Dict, Any + +from .provider import ScriptsProvider + +__all__ = ["ScriptsProvider"] + +# Provider metadata for registration +PROVIDER_METADATA = { + "name": "scripts", + "label": "Scripts Provider", + "description": "Universal script-based provider executor", + "supports_countries": ["*"], # All countries, scripts can handle country internally + "requires_auth": False, # Auth handled internally by scripts + "supports_live": True, + "supports_vod": True, # Scripts can implement VOD + "supports_radio": False, # Usually TV channels + "supports_drm": True, # Scripts can return DRM keys + "supports_catchup": False, # Not implemented + "supports_epg": False, # Not implemented + "formats": ["dash", "hls"], # Scripts determine format +} + + +# Discovery function for dynamic provider registration +def discover_script_providers(config_dir: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Discover all Python scripts and return metadata for provider registration + + This matches the pattern used by M3UProvider.discover_groups() + + Args: + config_dir: Configuration directory path + + Returns: + List of provider instance metadata dictionaries + """ + from .provider import ScriptsProvider + + try: + scripts = ScriptsProvider.discover_scripts(config_dir) + except Exception as e: + # If discovery fails, return empty list rather than crashing + import logging + logging.error(f"Failed to discover script providers: {e}") + return [] + + instances = [] + + for script in scripts: + # Clean name for provider identifier + name = os.path.splitext(script)[0] + clean_name = re.sub(r'[^a-z0-9_]+', '', name.lower()) + provider_name = clean_name if clean_name else "script" + + # Create readable label + label = name.replace('_', ' ').title() + + instances.append({ + "plugin": provider_name, + "script": script, + "label": label, + "country": "XX", # Default country - scripts handle country via params + "requires_country_suffix": False, # Scripts handle country via params + "provider_class": ScriptsProvider, + "init_kwargs": { + "script_filename": script, + } + }) + + return instances \ No newline at end of file diff --git a/lib/streaming_providers/providers/scripts/constants.py b/lib/streaming_providers/providers/scripts/constants.py new file mode 100644 index 0000000..c4e06f2 --- /dev/null +++ b/lib/streaming_providers/providers/scripts/constants.py @@ -0,0 +1,221 @@ +# streaming_providers/providers/scripts/constants.py +# ============================================================================ +# Scripts Provider Configuration +# ============================================================================ + +# Scripts provider logo (generic script/plugin icon) +SCRIPTS_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Python-logo-notext.svg/240px-Python-logo-notext.svg.png" + +# ============================================================================ +# Exception Classes +# ============================================================================ + +class ScriptProviderError(Exception): + """Base exception for script provider errors""" + pass + +class ScriptNotFoundError(ScriptProviderError): + """Raised when a script file cannot be found""" + pass + +class ScriptTimeoutError(ScriptProviderError): + """Raised when script execution times out""" + pass + +class ScriptExecutionError(ScriptProviderError): + """Raised when script execution fails""" + pass + +class InvalidScriptOutputError(ScriptProviderError): + """Raised when script output is invalid or malformed""" + pass + +class MissingActionError(ScriptProviderError): + """Raised when script doesn't support required action""" + pass + +# ============================================================================ +# Environment Variables +# ============================================================================ + +# Environment variable for scripts directory +# Similar to M3U_PLAYLISTS_PATH, this can be set to specify where provider scripts are located +# Example: SCRIPTS_PROVIDERS_PATH=/custom/scripts +ENV_SCRIPTS_PROVIDERS_PATH = "SCRIPTS_PROVIDERS_PATH" + +# ============================================================================ +# File Configuration +# ============================================================================ + +# Supported file extensions (only Python files) +SUPPORTED_EXTENSIONS = [".py"] + +# Excluded filenames (don't treat as providers) +EXCLUDED_FILENAMES = [ + "__init__.py", + "__pycache__", + "constants.py", + "provider.py", + "base.py", + "utils.py", +] + +# Default encoding for script files +DEFAULT_ENCODING = "utf-8" + +# ============================================================================ +# Script Execution Configuration +# ============================================================================ + +# Default timeout for script execution in seconds +# Prevents hanging scripts from blocking the provider +DEFAULT_SCRIPT_TIMEOUT = 30 + +# Maximum output size in bytes (1MB limit) +# Prevents scripts from generating massive output +MAX_OUTPUT_SIZE = 1048576 + +# ============================================================================ +# Action Types +# ============================================================================ + +# Valid actions a script must support +VALID_ACTIONS = ["channels", "manifest", "cdm"] + +# Action that returns channel list +ACTION_CHANNELS = "channels" + +# Action that returns manifest URL and headers +ACTION_MANIFEST = "manifest" + +# Action that returns DRM keys +ACTION_CDM = "cdm" + +# ============================================================================ +# Channel Configuration +# ============================================================================ + +# Default channel settings for script-provided channels +DEFAULT_CHANNEL_CONFIG = { + "video": "best", + "on_demand": True, + "speed_up": True, + "use_cdm": False, # Will be set to True if DRM config is provided + "cdm_mode": "external", + "session_manifest": True, # Scripts typically provide manifest on demand + "mode": "live", + "content_type": "LIVE", +} + +# Minimum required fields for a valid channel entry +REQUIRED_CHANNEL_FIELDS = ["Name", "ManifestScript"] + +# Channel field mappings from script output to StreamingChannel +CHANNEL_FIELD_MAPPING = { + "Name": "name", + "Id": "channel_id", + "Mode": "mode", + "SessionManifest": "session_manifest", + "ManifestScript": "manifest_script", + "CdmType": "cdm_type", + "UseCdm": "use_cdm", + "Cdm": "cdm", + "CdmMode": "cdm_mode", + "Video": "video", + "OnDemand": "on_demand", + "SpeedUp": "speed_up", + "LogoUrl": "logo_url", + "ChannelNumber": "channel_number", + "Quality": "quality", + "ContentType": "content_type", + "Country": "country", + "Language": "language", + "StreamingFormat": "streaming_format", +} + +# ============================================================================ +# Manifest Response Configuration +# ============================================================================ + +# Expected fields in manifest action response +REQUIRED_MANIFEST_FIELDS = ["ManifestUrl"] + +# Optional manifest response fields +OPTIONAL_MANIFEST_FIELDS = ["Headers", "Heartbeat"] + +# ============================================================================ +# DRM/ClearKey Configuration +# ============================================================================ + +# DRM system mapping for script outputs +DRM_SYSTEM_MAPPING = { + "widevine": "widevine", + "playready": "playready", + "clearkey": "clearkey", + "fairplay": "fairplay", + "wiseplay": "wiseplay", +} + +# Default DRM system if not specified +DEFAULT_DRM_SYSTEM = "widevine" + +# ============================================================================ +# Caching Configuration +# ============================================================================ + +# Cache TTL in seconds for channels list (5 minutes default) +# Channels don't change frequently +CHANNELS_CACHE_TTL = 300 + +# Cache TTL in seconds for DRM keys (1 hour default) +# Keys are usually static or long-lived +CDM_CACHE_TTL = 3600 + +# Don't cache manifest responses (they're ephemeral) +CACHE_MANIFEST = False + +# Enable auto-refresh when cache expires +AUTO_REFRESH_ON_EXPIRE = True + +# ============================================================================ +# Validation Configuration +# ============================================================================ + +# Skip channels with missing required fields +SKIP_INVALID_CHANNELS = True + +# Log warnings for channels with missing optional fields +WARN_MISSING_OPTIONAL_FIELDS = True + +# ============================================================================ +# Default Values +# ============================================================================ + +# Default country code for scripts without country specification +DEFAULT_COUNTRY = "XX" # Unknown/International + +# Default language code +DEFAULT_LANGUAGE = "und" # Undetermined + +# Default provider name prefix +DEFAULT_PROVIDER_PREFIX = "script" + +# ============================================================================ +# Proxy Configuration +# ============================================================================ + +# Proxy argument format for scripts +PROXY_ARG_FORMAT = "--proxy={}" + +# ============================================================================ +# Error Messages +# ============================================================================ + +ERROR_SCRIPT_NOT_FOUND = "Script not found: {filename}" +ERROR_SCRIPT_TIMEOUT = "Script execution timed out after {timeout}s: {filename}" +ERROR_SCRIPT_FAILED = "Script execution failed (exit code {code}): {filename}" +ERROR_INVALID_JSON = "Script returned invalid JSON: {error}" +ERROR_MISSING_ACTION = "Script does not support action '{action}': {filename}" +ERROR_CHANNELS_MISSING_FIELD = "Channel missing required field '{field}': {name}" +ERROR_MANIFEST_MISSING_FIELD = "Manifest response missing required field '{field}'" +ERROR_CDM_INVALID_FORMAT = "Invalid CDM key format (expected KID:KEY hex pairs): {line}" \ No newline at end of file diff --git a/lib/streaming_providers/providers/scripts/provider.py b/lib/streaming_providers/providers/scripts/provider.py new file mode 100644 index 0000000..5a9e9aa --- /dev/null +++ b/lib/streaming_providers/providers/scripts/provider.py @@ -0,0 +1,987 @@ +# streaming_providers/providers/scripts/provider.py +# -*- coding: utf-8 -*- +""" +Scripts Provider Implementation + +Executes Python scripts that implement streaming provider logic. +Each script must support actions: channels, manifest, cdm + +Environment Variable: + SCRIPTS_PROVIDERS_PATH: Directory containing provider scripts (auto-discovers *.py files) +""" + +import json +import os +import re +import subprocess +import sys +import time +from typing import ClassVar, Dict, List, Optional, Any + +from ...base.models.drm_models import DRMConfig, DRMSystem, LicenseConfig +from ...base.models.proxy_models import ProxyConfig +from ...base.models.streaming_channel import StreamingChannel +from ...base.provider import StreamingProvider +from ...base.utils.logger import logger +from ...base.utils.vfs import get_vfs +from .constants import ( + ACTION_CHANNELS, + ACTION_MANIFEST, + ACTION_CDM, + AUTO_REFRESH_ON_EXPIRE, + CHANNELS_CACHE_TTL, + CDM_CACHE_TTL, + CHANNEL_FIELD_MAPPING, + DEFAULT_CHANNEL_CONFIG, + DEFAULT_COUNTRY, + DEFAULT_ENCODING, + DEFAULT_DRM_SYSTEM, + DEFAULT_LANGUAGE, + DEFAULT_SCRIPT_TIMEOUT, + ENV_SCRIPTS_PROVIDERS_PATH, + ERROR_SCRIPT_NOT_FOUND, + ERROR_SCRIPT_TIMEOUT, + ERROR_INVALID_JSON, + ERROR_CHANNELS_MISSING_FIELD, + ERROR_MANIFEST_MISSING_FIELD, + ERROR_CDM_INVALID_FORMAT, + EXCLUDED_FILENAMES, + MAX_OUTPUT_SIZE, + PROXY_ARG_FORMAT, + REQUIRED_CHANNEL_FIELDS, + REQUIRED_MANIFEST_FIELDS, + SCRIPTS_LOGO, + SKIP_INVALID_CHANNELS, + SUPPORTED_EXTENSIONS, + # Import the new exception classes + ScriptNotFoundError, + ScriptTimeoutError, + ScriptExecutionError, + InvalidScriptOutputError, +) + + +class ScriptsProvider(StreamingProvider): + """ + Scripts Provider + + Executes Python scripts that provide streaming functionality. + Each script in the configured directory becomes a provider instance. + """ + + # ============================================================================ + # STATIC METADATA + # ============================================================================ + PROVIDER_LABEL: ClassVar[str] = "Scripts Provider" + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["anonymous"] # Auth handled internally + PROVIDER_LOGO: ClassVar[str] = SCRIPTS_LOGO + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["*"] # All countries + + def __init__( + self, + country: str = DEFAULT_COUNTRY, + config_dir: Optional[str] = None, + script_filename: Optional[str] = None, + proxy_config: Optional[ProxyConfig] = None, + proxy_url: Optional[str] = None, + script_timeout: int = DEFAULT_SCRIPT_TIMEOUT, + channels_cache_ttl: int = CHANNELS_CACHE_TTL, + cdm_cache_ttl: int = CDM_CACHE_TTL, + auto_refresh: bool = AUTO_REFRESH_ON_EXPIRE, + encoding: str = DEFAULT_ENCODING, + script_args: Optional[Dict[str, str]] = None, + ): + """ + Initialize Scripts provider + + Args: + country: Country code (default: XX for international) + config_dir: Configuration directory path + script_filename: Specific script filename (if None, auto-discovers all *.py files) + proxy_config: Proxy configuration object + proxy_url: Proxy URL string + script_timeout: Timeout in seconds for script execution + channels_cache_ttl: Cache TTL for channels list in seconds + cdm_cache_ttl: Cache TTL for DRM keys in seconds + auto_refresh: Auto-refresh cache when expired + encoding: File encoding for reading scripts + script_args: Additional static arguments to pass to scripts + + Environment Variables: + SCRIPTS_PROVIDERS_PATH: Directory containing script files (overrides config_dir) + """ + super().__init__(country=country) + + # Store script filename - this determines provider identity + self.script_filename = script_filename + self.script_timeout = script_timeout + self.channels_cache_ttl = channels_cache_ttl + self.cdm_cache_ttl = cdm_cache_ttl + self.auto_refresh = auto_refresh + self.encoding = encoding + self.script_args = script_args or {} + + # Determine scripts directory from environment or config_dir + scripts_path = os.environ.get(ENV_SCRIPTS_PROVIDERS_PATH) + if scripts_path: + logger.info(f"Scripts: Using {ENV_SCRIPTS_PROVIDERS_PATH} from environment: {scripts_path}") + self.scripts_dir = scripts_path + elif config_dir: + self.scripts_dir = config_dir + else: + # Use current directory as fallback + self.scripts_dir = "." + logger.warning( + "Scripts: No SCRIPTS_PROVIDERS_PATH or config_dir specified, using current directory" + ) + + # Initialize VFS for file operations + self.vfs = get_vfs(config_dir=self.scripts_dir) + + # Setup HTTP manager (minimal, scripts handle their own networking) + self.http_manager = self._setup_http_manager( + provider_name=self._get_base_provider_name(), + proxy_config=proxy_config, + proxy_url=proxy_url, + config_dir=config_dir, + ) + + # Internal state and caching + self._channels_cache: List[StreamingChannel] = [] + self._channels_cache_timestamp: float = 0 + self._cdm_cache: Dict[str, DRMConfig] = {} # channel_id -> DRMConfig + self._cdm_cache_timestamps: Dict[str, float] = {} + + # Statistics + self._stats = { + "script_executions": 0, + "script_failures": 0, + "channels_parsed": 0, + "channels_skipped": 0, + "drm_configs_loaded": 0, + "cache_hits": 0, + "cache_misses": 0, + } + + # Validate script exists if specific filename provided + if self.script_filename: + self._validate_script() + + # ============================================================================ + # STATIC METHODS FOR DISCOVERY + # ============================================================================ + + @staticmethod + def _get_base_provider_name() -> str: + """Get base provider name (always 'scripts') for internal use""" + return "scripts" + + @property + def provider_name(self) -> str: + """ + Provider name is the script filename without extension + + Examples: + - "discoveryplus.py" -> "discoveryplus" + - "dplay.py" -> "dplay" + - "peacock_us.py" -> "peacock_us" + """ + if self.script_filename: + # Remove extension + name = os.path.splitext(self.script_filename)[0] + # Clean name for provider identifier + clean_name = re.sub(r'[^a-z0-9_]+', '', name.lower()) + return clean_name if clean_name else "script" + return "scripts" + + @property + def provider_label(self) -> str: + """ + Provider label is the script filename (human readable) + + Examples: + - "discoveryplus.py" -> "Discoveryplus" + - "dplay.py" -> "Dplay" + - "peacock_us.py" -> "Peacock Us" + """ + if self.script_filename: + # Convert filename to nice label + name = os.path.splitext(self.script_filename)[0] + # Replace underscores with spaces and title case + label = name.replace('_', ' ').title() + return label + return self.PROVIDER_LABEL + + @property + def provider_logo(self) -> str: + return self.PROVIDER_LOGO + + @property + def supported_auth_types(self) -> List[str]: + return self.SUPPORTED_AUTH_TYPES + + @property + def uses_dynamic_manifests(self) -> bool: + return True # Scripts always generate manifests on demand + + @property + def implements_epg(self) -> bool: + return False # Scripts don't implement EPG + + # ============================================================================ + # SCRIPT DISCOVERY + # ============================================================================ + + def _validate_script(self) -> bool: + """Validate that the configured script exists and is readable""" + if not self.script_filename: + return False + + if not self.vfs.exists(self.script_filename): + logger.error(f"Scripts: Script not found: {self.script_filename}") + return False + + return True + + @classmethod + def discover_scripts(cls, config_dir: Optional[str] = None) -> List[str]: + """ + Discover all Python scripts in the scripts directory + + This is used for dynamic provider registration - each script becomes a provider. + + Args: + config_dir: Configuration directory path + + Returns: + List of script filenames (without directory) that can be providers + """ + try: + # Determine scripts directory + scripts_path = os.environ.get(ENV_SCRIPTS_PROVIDERS_PATH) + if scripts_path: + scripts_dir = scripts_path + elif config_dir: + scripts_dir = config_dir + else: + scripts_dir = "." + + # Initialize VFS + vfs = get_vfs(config_dir=scripts_dir) + + # Discover all Python files + discovered = [] + for ext in SUPPORTED_EXTENSIONS: + files = vfs.list_files("", pattern=f"*{ext}") + discovered.extend(files) + + # Remove duplicates and sort + discovered = sorted(set(discovered)) + + # Filter out excluded filenames + discovered = [ + f for f in discovered + if os.path.basename(f) not in EXCLUDED_FILENAMES + and not os.path.basename(f).startswith('_') + ] + + if not discovered: + logger.debug(f"Scripts: No provider scripts found in {scripts_dir}") + else: + logger.info(f"Scripts: Discovered {len(discovered)} provider scripts: {', '.join(discovered)}") + + return discovered + + except Exception as e: + logger.error(f"Scripts: Error during script discovery: {e}") + return [] + + # ============================================================================ + # SCRIPT EXECUTION + # ============================================================================ + + def _build_script_command( + self, + action: str, + proxy_url: Optional[str] = None, + timeout: Optional[int] = None, + **kwargs, + ) -> List[str]: + """ + Build command line for script execution + + Args: + action: Action to perform ('channels', 'manifest', 'cdm') + proxy_url: Optional proxy URL string + timeout: Optional custom timeout (uses self.script_timeout if None) + **kwargs: Additional parameters to pass to script + + Returns: + List of command arguments + """ + if not self.script_filename: + raise ValueError("No script filename configured") + + # Get full path to script + script_path = self.vfs.join_path(self.script_filename) + + # Base command + cmd = [sys.executable, script_path, "--action", action] + + # Add proxy if provided + if proxy_url: + cmd.append(PROXY_ARG_FORMAT.format(proxy_url)) + + # Add all kwargs as --key=value arguments + for key, value in kwargs.items(): + if value is not None: + # Convert value to string and escape if needed + cmd.append(f"--{key}={value}") + + # Add static script args (these come last and shouldn't override user kwargs) + for key, value in self.script_args.items(): + if key not in kwargs: # Only add if not already provided + cmd.append(f"--{key}={value}") + + return cmd + + def _execute_script( + self, + action: str, + proxy_url: Optional[str] = None, + timeout: Optional[int] = None, + **kwargs, + ) -> Optional[Dict[str, Any]]: + """ + Execute a script with the given action and parameters + + Args: + action: Action to perform + proxy_url: Optional proxy URL + timeout: Optional custom timeout (uses self.script_timeout if None) + **kwargs: Additional parameters + + Returns: + Parsed JSON output as dict, or None if execution failed + """ + if not self._validate_script(): + logger.error(ERROR_SCRIPT_NOT_FOUND.format(filename=self.script_filename)) + return None + + cmd = self._build_script_command(action, proxy_url, timeout, **kwargs) + execution_timeout = timeout if timeout is not None else self.script_timeout + + logger.debug(f"Scripts: Executing {self.provider_label}: {' '.join(cmd)}") + + try: + # Execute script with timeout + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=execution_timeout, + check=False, + encoding='utf-8', # Explicitly set encoding + errors='replace', # Replace invalid characters instead of failing + ) + + self._stats["script_executions"] += 1 + + # Check exit code + if result.returncode != 0: + self._stats["script_failures"] += 1 + stderr = result.stderr.strip() if result.stderr else "No error output" + logger.error( + f"Scripts: {self.provider_label} - " + f"Script failed (exit code {result.returncode}): {stderr}" + ) + return None + + # Check stdout size + if len(result.stdout) > MAX_OUTPUT_SIZE: + logger.error( + f"Scripts: {self.provider_label} - " + f"Output exceeds maximum size ({len(result.stdout)} > {MAX_OUTPUT_SIZE})" + ) + return None + + # Parse JSON output + try: + output = json.loads(result.stdout) + return output + except json.JSONDecodeError as e: + self._stats["script_failures"] += 1 + logger.error(ERROR_INVALID_JSON.format(error=e)) + logger.debug(f"Scripts: Raw output: {result.stdout[:500]}") + return None + + except subprocess.TimeoutExpired: + self._stats["script_failures"] += 1 + logger.error(ERROR_SCRIPT_TIMEOUT.format( + timeout=execution_timeout, + filename=self.script_filename + )) + return None + except Exception as e: + self._stats["script_failures"] += 1 + logger.error(f"Scripts: {self.provider_label} - Unexpected error: {e}") + return None + + # ============================================================================ + # CHANNEL PARSING + # ============================================================================ + + def _parse_channels_output(self, output: Dict[str, Any]) -> List[StreamingChannel]: + """ + Parse script output for action=channels into StreamingChannel objects + + Expected output format: + { + "Channels": [ + { + "Name": "Channel Name", + "Id": "channel_id", # optional + "Mode": "live", + "SessionManifest": True, + "ManifestScript": "chanid=123", + "CdmType": "widevine", + "UseCdm": True, + "Cdm": "chanid=123", + ... + } + ] + } + + Args: + output: Parsed JSON output from script + + Returns: + List of StreamingChannel objects + """ + channels = [] + + # Extract Channels array + channels_data = output.get("Channels", []) + if not isinstance(channels_data, list): + logger.warning(f"Scripts: {self.provider_label} - 'Channels' is not a list") + return [] + + for idx, channel_data in enumerate(channels_data): + try: + # Skip invalid channels + if not isinstance(channel_data, dict): + logger.warning(f"Scripts: {self.provider_label} - Channel {idx} is not a dict") + continue + + # Check required fields + missing_fields = [] + for field in REQUIRED_CHANNEL_FIELDS: + if field not in channel_data: + missing_fields.append(field) + + if missing_fields: + if SKIP_INVALID_CHANNELS: + logger.warning(ERROR_CHANNELS_MISSING_FIELD.format( + field=", ".join(missing_fields), + name=channel_data.get("Name", f"channel_{idx}") + )) + self._stats["channels_skipped"] += 1 + continue + + # Map fields to StreamingChannel format + mapped_data = { + "provider": self.provider_name, + "country": self.country, + "language": DEFAULT_LANGUAGE, + } + + # Apply field mapping + for script_field, channel_field in CHANNEL_FIELD_MAPPING.items(): + if script_field in channel_data: + mapped_data[channel_field] = channel_data[script_field] + + # Generate channel_id if not present + if "channel_id" not in mapped_data: + mapped_data["channel_id"] = self._generate_channel_id( + mapped_data.get("name", "unknown"), + idx + 1, + self.script_filename or "script" + ) + + # Apply default config + for key, value in DEFAULT_CHANNEL_CONFIG.items(): + if key not in mapped_data: + mapped_data[key] = value + + # Create channel object + channel = StreamingChannel(**mapped_data) + channels.append(channel) + self._stats["channels_parsed"] += 1 + + except Exception as e: + logger.error(f"Scripts: {self.provider_label} - Error parsing channel {idx}: {e}") + self._stats["channels_skipped"] += 1 + + return channels + + @staticmethod + def _generate_channel_id(name: str, index: int, script_name: str) -> str: + """Generate a unique channel ID from name and script""" + # Clean name: lowercase, remove special chars, replace spaces with underscores + clean_name = re.sub(r"[^a-z0-9\s]", "", name.lower()) + clean_name = re.sub(r"\s+", "_", clean_name.strip()) + + # Clean script name + clean_script = re.sub(r"[^a-z0-9]", "_", script_name.lower()) + clean_script = clean_script.replace(".py", "") + + return f"{clean_script}_{clean_name}_{index}" + + # ============================================================================ + # DRM PARSING + # ============================================================================ + + def _parse_cdm_output( + self, + output: Dict[str, Any], + channel_id: str, + ) -> Optional[DRMConfig]: + """ + Parse script output for action=cdm into DRMConfig + + Expected output format: + { + "keyids": { + "0123456789abcdef0123456789abcdef": "fedcba9876543210fedcba9876543210", + ... + }, + "system": "widevine" # optional, defaults to widevine + } + + Or list of hex pairs: + [ + "0123456789abcdef0123456789abcdef:fedcba9876543210fedcba9876543210", + ... + ] + + Args: + output: Parsed JSON output from script + channel_id: Channel ID for logging + + Returns: + DRMConfig object or None + """ + try: + keyids = {} + drm_system = DEFAULT_DRM_SYSTEM + + # Case 1: Dictionary format with keyids + if isinstance(output, dict): + # Extract system if present + if "system" in output: + drm_system = output["system"].lower() + + # Extract keyids + if "keyids" in output and isinstance(output["keyids"], dict): + for kid, key in output["keyids"].items(): + # Clean hex strings (remove dashes and spaces) + kid_clean = kid.lower().replace("-", "").replace(" ", "") + key_clean = key.lower().replace("-", "").replace(" ", "") + + # Validate 128-bit hex (32 chars) + if len(kid_clean) == 32 and all(c in "0123456789abcdef" for c in kid_clean): + if len(key_clean) == 32 and all(c in "0123456789abcdef" for c in key_clean): + keyids[kid_clean] = key_clean + else: + logger.warning(f"Scripts: {self.provider_label} - Invalid key hex: {key}") + else: + logger.warning(f"Scripts: {self.provider_label} - Invalid KID hex: {kid}") + + # Case 2: List format with "KID:KEY" pairs + elif isinstance(output, list): + for item in output: + if isinstance(item, str) and ":" in item: + kid, key = item.split(":", 1) + kid = kid.lower().replace("-", "").replace(" ", "") + key = key.lower().replace("-", "").replace(" ", "") + + if len(kid) == 32 and all(c in "0123456789abcdef" for c in kid): + if len(key) == 32 and all(c in "0123456789abcdef" for c in key): + keyids[kid] = key + else: + logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=item)) + else: + logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=item)) + else: + logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=str(item))) + + if not keyids: + logger.debug(f"Scripts: {self.provider_label} - No valid keys found for channel {channel_id}") + return None + + # Resolve DRM system + drm_system_enum = DRMSystem.from_alias(drm_system) or DRMSystem.WIDEVINE + + # Create DRM config + license_config = LicenseConfig( + keyids=keyids, + ) + + drm_config = DRMConfig( + system=drm_system_enum, + license=license_config, + ) + + self._stats["drm_configs_loaded"] += 1 + logger.debug(f"Scripts: {self.provider_label} - Loaded {len(keyids)} keys for {channel_id}") + + return drm_config + + except Exception as e: + logger.error(f"Scripts: {self.provider_label} - Error parsing CDM output: {e}") + return None + + # ============================================================================ + # CHANNEL MANAGEMENT + # ============================================================================ + + def _should_refresh_channels_cache(self) -> bool: + """Check if channels cache should be refreshed""" + if not self._channels_cache: + return True + + if self.auto_refresh: + age = time.time() - self._channels_cache_timestamp + return age >= self.channels_cache_ttl + + return False + + def _should_refresh_cdm_cache(self, channel_id: str) -> bool: + """Check if CDM cache for a channel should be refreshed""" + if channel_id not in self._cdm_cache: + return True + + if self.auto_refresh: + age = time.time() - self._cdm_cache_timestamps.get(channel_id, 0) + return age >= self.cdm_cache_ttl + + return False + + def get_channels( + self, + fetch_manifests: bool = False, + populate_streaming_data: bool = True, + force_refresh: bool = False, + proxy_url: Optional[str] = None, + **kwargs, + ) -> List[StreamingChannel]: + """ + Get channels by executing script with action=channels + + Args: + fetch_manifests: Not used (manifests are generated on demand) + populate_streaming_data: Not used (data comes from script) + force_refresh: Force re-execution of script + proxy_url: Optional proxy URL to pass to script + **kwargs: Additional arguments passed to script + + Returns: + List of StreamingChannel objects + """ + # Check cache + if not force_refresh and not self._should_refresh_channels_cache(): + if self._channels_cache: + self._stats["cache_hits"] += 1 + logger.debug(f"Scripts ({self.provider_label}): Returning channels from cache") + return self._channels_cache.copy() + + self._stats["cache_misses"] += 1 + logger.info(f"Scripts ({self.provider_label}): Executing script to fetch channels...") + + # Execute script + output = self._execute_script( + action=ACTION_CHANNELS, + proxy_url=proxy_url, + **kwargs, + ) + + if not output: + # Return cached data if available, otherwise empty list + if self._channels_cache: + logger.warning(f"Scripts ({self.provider_label}): Script failed, using stale cache") + return self._channels_cache.copy() + return [] + + # Parse channels + channels = self._parse_channels_output(output) + + # Update cache + self._channels_cache = channels + self._channels_cache_timestamp = time.time() + + logger.info( + f"Scripts ({self.provider_label}): Loaded {len(channels)} channels " + f"(parsed: {self._stats['channels_parsed']}, skipped: {self._stats['channels_skipped']})" + ) + + return channels + + def get_manifest( + self, + channel_id: str, + proxy_url: Optional[str] = None, + **kwargs, + ) -> Optional[str]: + """ + Get manifest URL for a specific channel by executing script with action=manifest + + Args: + channel_id: Channel identifier + proxy_url: Optional proxy URL to pass to script + **kwargs: Additional arguments passed to script (merged with channel manifest script) + + Returns: + Manifest URL string or None + """ + # Find channel to get manifest script parameters + channel = None + channels = self.get_channels() + for ch in channels: + if ch.channel_id == channel_id: + channel = ch + break + + if not channel: + logger.warning(f"Scripts ({self.provider_label}): Channel not found: {channel_id}") + return None + + # Parse manifest script parameters + manifest_params = {} + if channel.manifest_script: + # Parse key=value pairs + for param in channel.manifest_script.split("&"): + if "=" in param: + key, value = param.split("=", 1) + manifest_params[key] = value + + # Merge with provided kwargs (kwargs take precedence) + script_kwargs = {**manifest_params, **kwargs} + + # Execute script + output = self._execute_script( + action=ACTION_MANIFEST, + proxy_url=proxy_url, + **script_kwargs, + ) + + if not output: + return None + + # Validate required fields + for field in REQUIRED_MANIFEST_FIELDS: + if field not in output: + logger.error(ERROR_MANIFEST_MISSING_FIELD.format(field=field)) + return None + + # Extract manifest URL + manifest_url = output.get("ManifestUrl") + if not manifest_url: + logger.error("Scripts: ManifestUrl is empty") + return None + + # Note: Headers and Heartbeat are stored in the channel/drm config + # This is handled by the player, not the provider + + return manifest_url + + def get_drm( + self, + channel_id: str, + proxy_url: Optional[str] = None, + force_refresh: bool = False, + **kwargs, + ) -> List[DRMConfig]: + """ + Get DRM configuration for a specific channel + + Args: + channel_id: Channel identifier + proxy_url: Optional proxy URL to pass to script + force_refresh: Force re-fetching of DRM keys + **kwargs: Additional arguments passed to script + + Returns: + List of DRMConfig objects (empty if no DRM) + """ + # Find channel to get CDM parameters + channel = None + channels = self.get_channels() + for ch in channels: + if ch.channel_id == channel_id: + channel = ch + break + + if not channel: + logger.warning(f"Scripts ({self.provider_label}): Channel not found: {channel_id}") + return [] + + # Check cache + if not force_refresh and not self._should_refresh_cdm_cache(channel_id): + cached_config = self._cdm_cache.get(channel_id) + if cached_config: + self._stats["cache_hits"] += 1 + logger.debug(f"Scripts ({self.provider_label}): Returning DRM config from cache for {channel_id}") + return [cached_config] + + self._stats["cache_misses"] += 1 + + # Parse CDM parameters from channel + cdm_params = {} + if channel.cdm: + # Parse key=value pairs + for param in channel.cdm.split("&"): + if "=" in param: + key, value = param.split("=", 1) + cdm_params[key] = value + + # Merge with provided kwargs + script_kwargs = {**cdm_params, **kwargs} + + # Execute script + output = self._execute_script( + action=ACTION_CDM, + proxy_url=proxy_url, + **script_kwargs, + ) + + if not output: + return [] + + # Parse DRM config + drm_config = self._parse_cdm_output(output, channel_id) + + if drm_config: + # Update cache + self._cdm_cache[channel_id] = drm_config + self._cdm_cache_timestamps[channel_id] = time.time() + return [drm_config] + + return [] + + def enrich_channel_data( + self, + channel: StreamingChannel, + **kwargs, + ) -> Optional[StreamingChannel]: + """ + Scripts provider channels are already fully populated + + Returns: + Same channel object + """ + return channel + + def get_dynamic_manifest_params( + self, + channel: StreamingChannel, + **kwargs, + ) -> Optional[str]: + """ + Get manifest script parameters for a channel + + Returns: + Manifest script string or None + """ + return channel.manifest_script if channel.session_manifest else None + + # ============================================================================ + # UTILITY METHODS + # ============================================================================ + + def get_statistics(self) -> Dict[str, Any]: + """Get execution statistics""" + return { + **self._stats, + "script_filename": self.script_filename, + "scripts_dir": self.scripts_dir, + "channels_cache_age": time.time() - self._channels_cache_timestamp if self._channels_cache_timestamp else 0, + "cached_channels": len(self._channels_cache), + "cached_drm_configs": len(self._cdm_cache), + } + + def test_script(self) -> Dict[str, Any]: + """ + Test if script is executable and returns valid channels + + Returns: + Test result dictionary + """ + result: Dict[str, Any] = { + "provider": self.provider_name, + "label": self.provider_label, + "script": self.script_filename, + "scripts_dir": self.scripts_dir, + "success": False, + "script_exists": False, + "script_executable": False, + "channels_found": 0, + "error": None, + } + + try: + # Check script exists + if not self.script_filename: + result["error"] = "No script filename configured" + return result + + result["script_exists"] = self.vfs.exists(self.script_filename) + + if not result["script_exists"]: + result["error"] = f"Script not found: {self.script_filename}" + return result + + # Try to execute script with channels action + output = self._execute_script(ACTION_CHANNELS, timeout=10) + + if output: + result["script_executable"] = True + + # Parse channels + channels = self._parse_channels_output(output) + result["channels_found"] = len(channels) + + # Check if we got valid channels + if channels: + result["success"] = True + result["sample_channels"] = [ + {"name": c.name, "id": c.channel_id} + for c in channels[:5] # First 5 channels as sample + ] + else: + result["error"] = "Script execution failed or returned no output" + + except Exception as e: + result["error"] = str(e) + + result["statistics"] = self.get_statistics() + return result + + def clear_caches(self) -> None: + """Clear all caches""" + self._channels_cache = [] + self._channels_cache_timestamp = 0 + self._cdm_cache.clear() + self._cdm_cache_timestamps.clear() + logger.info(f"Scripts ({self.provider_label}): Caches cleared") + + def close(self) -> None: + """Clean up resources""" + if hasattr(self, "http_manager") and self.http_manager: + self.http_manager.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False \ No newline at end of file