Files
script.service.ultimate/lib/streaming_providers/providers/scripts/provider.py
T
2026-02-11 20:51:40 +01:00

987 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
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