mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 15:12:12 +02:00
995 lines
34 KiB
Python
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 |