Files
script.service.ultimate/lib/streaming_providers/providers/scripts/provider.py
T
2026-03-03 09:55:39 +01:00

995 lines
34 KiB
Python

# 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
import datetime
from typing import ClassVar, Dict, List, Optional, Any
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel, Event
from ...base.models.proxy_models import ProxyConfig
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_events(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs,
) -> List[Event]:
return []
def get_manifest(
self,
content_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 == content_id:
channel = ch
break
if not channel:
logger.warning(f"Scripts ({self.provider_label}): Channel not found: {content_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,
content_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 == content_id:
channel = ch
break
if not channel:
logger.warning(f"Scripts ({self.provider_label}): Channel not found: {content_id}")
return []
# Check cache
if not force_refresh and not self._should_refresh_cdm_cache(content_id):
cached_config = self._cdm_cache.get(content_id)
if cached_config:
self._stats["cache_hits"] += 1
logger.debug(f"Scripts ({self.provider_label}): Returning DRM config from cache for {content_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, content_id)
if drm_config:
# Update cache
self._cdm_cache[content_id] = drm_config
self._cdm_cache_timestamps[content_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