mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-02 14:02:32 +02:00
Add m3u playlists
This commit is contained in:
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user