Files
script.service.ultimate/lib/streaming_providers/base/drm/plugin_manager.py
T
2026-02-13 17:07:25 +01:00

605 lines
24 KiB
Python

# streaming_providers/base/drm/plugin_manager.py
"""
Manager for DRM configuration plugins with two-phase processing.
Handles plugin registration, discovery, and processing of DRM configs with PSSH data.
"""
import traceback
from typing import Dict, List, Optional
from ..models.drm import DRMConfig, DRMSystem, PSSHData
from ..utils.logger import logger
from .drm_plugin import DRMPlugin
class DRMPluginManager:
"""
Manager for DRM configuration plugins.
Supports two-phase processing:
- Phase 1: GENERIC plugins (config generators, run before provider)
- Phase 2: System-specific plugins (config transformers, run after provider)
"""
def __init__(self, auto_discover: bool = True):
"""Initialize with empty plugin registry and optionally auto-discover plugins"""
self.plugins: Dict[DRMSystem, DRMPlugin] = {}
logger.debug("DRMPluginManager: Initialized with empty plugin registry")
if auto_discover:
logger.debug("DRMPluginManager: Auto-discovery enabled, discovering plugins...")
discovered = self.discover_plugins()
if discovered:
logger.debug(
f"DRMPluginManager: Auto-discovery completed, {len(discovered)} plugins ready"
)
else:
logger.debug("DRMPluginManager: Auto-discovery completed, no plugins found")
def register_plugin(self, plugin: DRMPlugin) -> None:
"""
Register a single plugin instance.
Args:
plugin: Configured plugin instance to register
Raises:
ValueError: If plugin is invalid or DRM system already has a plugin
"""
if not isinstance(plugin, DRMPlugin):
logger.warning(
f"DRMPluginManager: Registration failed - invalid plugin type: {type(plugin)}"
)
raise ValueError("Only DRMPlugin instances can be registered")
drm_system = plugin.supported_drm_system
plugin_name = plugin.plugin_name
if drm_system in self.plugins:
existing_plugin = self.plugins[drm_system].plugin_name
logger.warning(
f"DRMPluginManager: Overwriting existing plugin '{existing_plugin}' "
f"with '{plugin_name}' for DRM system {drm_system}"
)
self.plugins[drm_system] = plugin
phase = "1-GENERIC" if drm_system == DRMSystem.GENERIC else "2-System-specific"
logger.info(
f"DRMPluginManager: Successfully registered plugin '{plugin_name}' "
f"for DRM system {drm_system} (Phase: {phase})"
)
def discover_plugins(self) -> List[str]:
"""
Discover and register all available DRM plugins by scanning filesystem.
Scans only the plugins directory (not subdirectories) for Python files
containing classes that inherit from DRMPlugin.
Returns:
List of discovered plugin names
"""
import importlib.util
import inspect
import os
logger.debug("DRMPluginManager: Starting filesystem-based plugin autodiscovery")
# Get the directory where this plugin manager is located
current_dir = os.path.dirname(os.path.abspath(__file__))
plugins_dir = os.path.join(current_dir, "plugins") # Scan the plugins subfolder
# Check if plugins directory exists
if not os.path.exists(plugins_dir):
logger.debug(f"DRMPluginManager: Plugins directory does not exist: {plugins_dir}")
return []
logger.debug(f"DRMPluginManager: Scanning plugins directory: {plugins_dir}")
registered = []
failed_plugins = []
scanned_files = []
# Scan only the plugins directory (no subdirectories)
try:
files = os.listdir(plugins_dir)
except OSError as e:
logger.warning(f"DRMPluginManager: Error reading plugins directory: {e}")
return []
for filename in files:
file_path = os.path.join(plugins_dir, filename)
# Only process Python files (not directories or other files)
if (
filename.endswith(".py")
and not filename.startswith("__")
and os.path.isfile(file_path)
):
scanned_files.append(filename)
logger.debug(f"DRMPluginManager: Scanning file: {filename}")
try:
# Create module name from filename
module_name = os.path.splitext(filename)[0]
# Import the module dynamically
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
logger.debug(
f"DRMPluginManager: Could not create module spec for {filename}"
)
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find all classes in the module that inherit from DRMPlugin
plugin_classes = []
for name, obj in inspect.getmembers(module, inspect.isclass):
# Check if it's a DRMPlugin subclass (but not DRMPlugin itself)
if (
issubclass(obj, DRMPlugin)
and obj is not DRMPlugin
and obj.__module__ == module.__name__
):
plugin_classes.append((name, obj))
if not plugin_classes:
logger.debug(f"DRMPluginManager: No DRMPlugin classes found in {filename}")
continue
logger.debug(
f"DRMPluginManager: Found {len(plugin_classes)} plugin class(es) in {filename}: {[name for name, _ in plugin_classes]}"
)
# Instantiate and register each plugin class found
for class_name, plugin_class in plugin_classes:
try:
logger.debug(
f"DRMPluginManager: Attempting to instantiate {class_name} from {filename}"
)
# Create plugin instance
plugin = plugin_class()
plugin_name = plugin.plugin_name
drm_system = plugin.supported_drm_system
logger.debug(
f"DRMPluginManager: Successfully created plugin '{plugin_name}' (class: {class_name}) supporting {drm_system}"
)
# Register the plugin
self.register_plugin(plugin)
registered.append(plugin_name)
logger.debug(
f"DRMPluginManager: Plugin '{plugin_name}' from {filename} successfully registered"
)
except Exception as e:
error_msg = (
f"Failed to instantiate {class_name} from {filename}: {str(e)}"
)
failed_plugins.append((f"{filename}::{class_name}", error_msg))
logger.warning(f"DRMPluginManager: {error_msg}")
except Exception as e:
traceback_str = traceback.format_exc()
error_msg = f"Failed to process file {filename}: {str(e)}\n{traceback_str}"
failed_plugins.append((filename, error_msg))
logger.warning(f"DRMPluginManager: {error_msg}")
# Log scanning summary
logger.debug(
f"DRMPluginManager: Filesystem scan completed - scanned {len(scanned_files)} Python files"
)
if scanned_files:
logger.debug(f"DRMPluginManager: Scanned files: {scanned_files}")
# Log final discovery results
if registered:
logger.info(
f"DRMPluginManager: Filesystem autodiscovery completed - {len(registered)} plugins registered: {registered}"
)
else:
logger.debug(
"DRMPluginManager: Filesystem autodiscovery completed - no plugins were registered"
)
if failed_plugins:
logger.debug(f"DRMPluginManager: {len(failed_plugins)} plugins/files failed to load:")
for plugin_name, error in failed_plugins:
logger.debug(f" - {plugin_name}: {error}")
return registered
def has_generic_plugins(self) -> bool:
"""Check if any GENERIC plugins are registered"""
return DRMSystem.GENERIC in self.plugins
def has_system_specific_plugins(self) -> bool:
"""Check if any system-specific (non-GENERIC) plugins are registered"""
return any(sys != DRMSystem.GENERIC for sys in self.plugins.keys())
def process_generic_plugins(
self,
dummy_configs: List[DRMConfig],
pssh_data_list: List[PSSHData],
**kwargs
) -> List[DRMConfig]:
"""
PHASE 1: Process through GENERIC plugins only.
GENERIC plugins can CREATE configs from PSSH data.
They receive a dummy config (DRMSystem.NONE) and PSSH data,
and should return valid DRM configs or None.
Args:
dummy_configs: List with dummy config [DRMConfig(system=DRMSystem.NONE)]
pssh_data_list: PSSH data extracted from manifest
**kwargs: Additional context
Returns:
List of generated DRM configs, or empty list if generation failed
"""
if not self.has_generic_plugins():
logger.debug("Phase 1: No GENERIC plugins registered")
return []
if not pssh_data_list:
logger.debug("Phase 1: No PSSH data available for GENERIC plugins")
return []
plugin = self.plugins[DRMSystem.GENERIC]
logger.debug(f"Phase 1: Processing with GENERIC plugin '{plugin.plugin_name}'")
generated_configs = []
try:
# Process each PSSH data entry
for pssh_data in pssh_data_list:
logger.debug(
f"Phase 1: Processing PSSH for system {pssh_data.system_id} "
f"with plugin '{plugin.plugin_name}'"
)
# Pass dummy config - plugin should return real config(s) or None
result = plugin.process_drm_config(
dummy_configs[0], # Dummy config
pssh_data,
**kwargs
)
if result:
generated_configs.append(result)
logger.debug(
f"Phase 1: Plugin '{plugin.plugin_name}' generated "
f"{result.system.value} config"
)
# If ClearKey found, return immediately
if result.system == DRMSystem.CLEARKEY:
logger.info(
f"Phase 1: ClearKey config found from GENERIC plugin, "
f"returning immediately"
)
return [result]
except Exception as e:
logger.error(
f"Phase 1: GENERIC plugin '{plugin.plugin_name}' failed: {e}",
exc_info=True
)
return []
if generated_configs:
logger.info(
f"Phase 1: GENERIC plugin generated {len(generated_configs)} configs"
)
else:
logger.debug("Phase 1: GENERIC plugin generated no configs")
return generated_configs
def process_system_specific_plugins(
self,
drm_configs: List[DRMConfig],
pssh_data_list: List[PSSHData],
**kwargs
) -> List[DRMConfig]:
"""
PHASE 2: Process through system-specific plugins (EXCLUDE GENERIC).
"""
if not drm_configs:
logger.debug("Phase 2: No DRM configs to process")
return []
# Get system-specific plugins only (exclude GENERIC)
# ✅ Add explicit type annotation
from typing import Any # or import your DRMPlugin type
system_plugins: dict[DRMSystem, Any] = {
sys: plugin for sys, plugin in self.plugins.items()
if sys != DRMSystem.GENERIC
}
if not system_plugins:
logger.debug("Phase 2: No system-specific plugins registered, returning provider configs")
return drm_configs
logger.debug(
f"Phase 2: Processing {len(drm_configs)} configs through "
f"{len(system_plugins)} system-specific plugins"
)
# Create a mapping of DRM system to PSSH data for quick lookup
# ✅ Add explicit type annotation
pssh_by_system: dict[DRMSystem, PSSHData] = {}
for pssh_data in pssh_data_list:
drm_sys = pssh_data.drm_system
if drm_sys is not None:
pssh_by_system[drm_sys] = pssh_data
logger.debug(
f"Phase 2: Mapped PSSH data for DRM system: {drm_sys}"
)
processed_configs = []
# Process each config through matching plugin
for config in drm_configs:
logger.debug(f"Phase 2: Processing DRM config for system: {config.system}")
if config.system in system_plugins:
plugin = system_plugins[config.system]
pssh_data = pssh_by_system.get(config.system)
if pssh_data:
logger.debug(
f"Phase 2: Using PSSH data for DRM system {config.system}"
)
else:
logger.debug(
f"Phase 2: No PSSH data available for DRM system {config.system}"
)
try:
logger.debug(
f"Phase 2: Processing with plugin '{plugin.plugin_name}'"
)
result = plugin.process_drm_config(config, pssh_data, **kwargs)
if result is not None:
# Check for ClearKey and return immediately if found
if result.system == DRMSystem.CLEARKEY:
logger.info(
f"Phase 2: ClearKey config found from plugin "
f"'{plugin.plugin_name}', returning immediately"
)
return [result]
processed_configs.append(result)
logger.debug(
f"Phase 2: Plugin '{plugin.plugin_name}' successfully "
f"processed config"
)
else:
logger.debug(
f"Phase 2: Plugin '{plugin.plugin_name}' filtered out config "
f"(returned None)"
)
except Exception as e:
logger.warning(
f"Phase 2: Plugin '{plugin.plugin_name}' failed to process "
f"config: {str(e)}"
)
# On error, keep original config
processed_configs.append(config)
logger.debug(f"Phase 2: Using original config as fallback")
else:
# No plugin for this system, keep original
logger.debug(
f"Phase 2: No plugin registered for DRM system {config.system}, "
f"passing through unchanged"
)
processed_configs.append(config)
logger.info(
f"Phase 2: Processed {len(drm_configs)} configs → {len(processed_configs)} configs"
)
return processed_configs
def process_drm_configs(
self,
drm_configs: List[DRMConfig],
pssh_data_list: List[PSSHData],
**kwargs
) -> List[DRMConfig]:
"""
Legacy method: Process configs through all plugins (generic first, then specific).
This maintains backward compatibility but uses single-phase processing.
For new code using two-phase flow, use process_generic_plugins() and
process_system_specific_plugins() separately via DRMOperations.
Args:
drm_configs: List of DRM configs to process
pssh_data_list: List of PSSH data extracted from manifest
**kwargs: Additional context from the original method call
Returns:
List of processed DRM configs (may be modified, filtered, or unchanged)
"""
if not drm_configs:
logger.debug("DRMPluginManager: No DRM configs to process")
return drm_configs
logger.debug(
f"DRMPluginManager: Processing {len(drm_configs)} DRM configs with "
f"{len(pssh_data_list)} PSSH data entries (legacy single-phase mode)"
)
# Create a mapping of DRM system to PSSH data for quick lookup
pssh_by_system = {}
for pssh_data in pssh_data_list:
if pssh_data.drm_system:
pssh_by_system[pssh_data.drm_system] = pssh_data
logger.debug(
f"DRMPluginManager: Mapped PSSH data for DRM system: {pssh_data.drm_system}"
)
# Separate generic and specific plugins
generic_plugins = []
specific_plugins = []
for drm_system, plugin in self.plugins.items():
if drm_system == DRMSystem.GENERIC:
generic_plugins.append(plugin)
logger.debug(f"DRMPluginManager: Found generic plugin '{plugin.plugin_name}'")
else:
specific_plugins.append((drm_system, plugin))
# Process generic plugins first
processed_configs = list(drm_configs)
for plugin in generic_plugins:
logger.debug(
f"DRMPluginManager: Processing through generic plugin '{plugin.plugin_name}'"
)
try:
# Generic plugins process all configs at once
temp_configs = []
for config in processed_configs:
pssh_data = pssh_by_system.get(config.system)
result = plugin.process_drm_config(config, pssh_data, **kwargs)
if result is not None:
temp_configs.append(result)
# Check for ClearKey and return immediately if found
for config in temp_configs:
if config.system == DRMSystem.CLEARKEY:
logger.info(
f"DRMPluginManager: ClearKey config found from generic plugin, "
f"returning immediately"
)
return [config]
processed_configs = temp_configs
except Exception as e:
logger.warning(
f"DRMPluginManager: Generic plugin '{plugin.plugin_name}' failed: {str(e)}"
)
continue
# Process specific plugins
final_configs = []
for config in processed_configs:
logger.debug(f"DRMPluginManager: Processing DRM config for system: {config.system}")
# Find specific plugin for this DRM system
plugin = self.plugins.get(config.system)
if plugin and plugin.supported_drm_system != DRMSystem.GENERIC:
logger.debug(
f"DRMPluginManager: Found plugin '{plugin.plugin_name}' for "
f"DRM system {config.system}"
)
try:
pssh_data = pssh_by_system.get(config.system)
if pssh_data:
logger.debug(
f"DRMPluginManager: Using PSSH data for DRM system {config.system}"
)
else:
logger.debug(
f"DRMPluginManager: No PSSH data available for "
f"DRM system {config.system}"
)
processed_config = plugin.process_drm_config(config, pssh_data, **kwargs)
if processed_config is not None:
# Check for ClearKey and return immediately if found
if processed_config.system == DRMSystem.CLEARKEY:
logger.info(
f"DRMPluginManager: ClearKey config found from plugin "
f"'{plugin.plugin_name}', returning immediately"
)
return [processed_config]
final_configs.append(processed_config)
logger.debug(
f"DRMPluginManager: Plugin '{plugin.plugin_name}' successfully "
f"processed config"
)
else:
logger.debug(
f"DRMPluginManager: Plugin '{plugin.plugin_name}' filtered out "
f"config (returned None)"
)
except Exception as e:
logger.warning(
f"DRMPluginManager: Plugin '{plugin.plugin_name}' failed to process "
f"config: {str(e)}"
)
final_configs.append(config)
logger.debug(f"DRMPluginManager: Using original config as fallback")
else:
logger.debug(
f"DRMPluginManager: No plugin registered for DRM system {config.system}, "
f"passing through unchanged"
)
final_configs.append(config)
logger.debug(
f"DRMPluginManager: Completed processing - {len(final_configs)} configs returned"
)
return final_configs
def get_plugin(self, drm_system: DRMSystem) -> Optional[DRMPlugin]:
"""
Get registered plugin for a DRM system.
Args:
drm_system: DRM system to get plugin for
Returns:
The plugin instance or None if not found
"""
plugin = self.plugins.get(drm_system)
if plugin:
logger.debug(
f"DRMPluginManager: Retrieved plugin '{plugin.plugin_name}' for "
f"DRM system {drm_system}"
)
else:
logger.debug(f"DRMPluginManager: No plugin found for DRM system {drm_system}")
return plugin
def list_plugins(self) -> Dict:
"""
List all registered plugins with phase information.
Returns:
Dictionary mapping DRM system values to plugin info
"""
plugin_list = {
drm_system.value: {
"name": plugin.plugin_name,
"system": drm_system.value,
"phase": "1-GENERIC" if drm_system == DRMSystem.GENERIC else "2-System-specific"
}
for drm_system, plugin in self.plugins.items()
}
logger.debug(f"DRMPluginManager: Currently registered plugins: {plugin_list}")
return plugin_list
def clear_plugins(self) -> None:
"""Clear all registered plugins"""
plugin_count = len(self.plugins)
self.plugins.clear()
logger.info(f"DRMPluginManager: Cleared {plugin_count} registered plugins")