Files
media-player-server/media_server/routes/scripts.py
alexei.dolgolyov 9404b37f05 Codebase audit fixes: stability, performance, accessibility
- Fix CORS: set allow_credentials=False (token auth, not cookies)
- Add threading.Lock for position cache thread safety
- Add shutdown_executor() for clean ThreadPoolExecutor cleanup
- Dedicated ThreadPoolExecutors for script/callback execution
- Fix Mutagen file handle leaks with try/finally close
- Reduce idle WebSocket polling (0.5s → 2.0s when no clients)
- Add :focus-visible styles for playback control buttons
- Add aria-label to icon-only header buttons
- Dynamic album art alt text for screen readers
- Persist MDI icon cache to localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:10:24 +03:00

370 lines
11 KiB
Python

"""Script execution API endpoints."""
import asyncio
import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import ScriptConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__)
class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional arguments."""
args: list[str] = Field(default_factory=list, description="Additional arguments")
class ScriptExecuteResponse(BaseModel):
"""Response model for script execution."""
success: bool
script: str
exit_code: int | None = None
stdout: str = ""
stderr: str = ""
error: str | None = None
execution_time: float | None = None
class ScriptInfo(BaseModel):
"""Information about an available script."""
name: str
label: str
command: str
description: str
icon: str | None = None
timeout: int
@router.get("/list")
async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
"""List all available scripts.
Returns:
List of available scripts with their descriptions
"""
return [
ScriptInfo(
name=name,
label=config.label or name.replace("_", " ").title(),
command=config.command,
description=config.description,
icon=config.icon,
timeout=config.timeout,
)
for name, config in settings.scripts.items()
]
@router.post("/execute/{script_name}")
async def execute_script(
script_name: str,
request: ScriptExecuteRequest | None = None,
_: str = Depends(verify_token),
) -> ScriptExecuteResponse:
"""Execute a pre-defined script by name.
Args:
script_name: Name of the script to execute (must be defined in config)
request: Optional arguments to pass to the script
Returns:
Execution result including stdout, stderr, and exit code
"""
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
)
script_config = settings.scripts[script_name]
args = request.args if request else []
logger.info(f"Executing script: {script_name}")
try:
# Build command
command = script_config.command
if args:
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
_script_executor,
lambda: _run_script(
command=command,
timeout=script_config.timeout,
shell=script_config.shell,
working_dir=script_config.working_dir,
),
)
return ScriptExecuteResponse(
success=result["exit_code"] == 0,
script=script_name,
exit_code=result["exit_code"],
stdout=result["stdout"],
stderr=result["stderr"],
execution_time=result.get("execution_time"),
)
except Exception as e:
logger.error(f"Script execution error: {e}")
return ScriptExecuteResponse(
success=False,
script=script_name,
error=str(e),
)
def _run_script(
command: str,
timeout: int,
shell: bool,
working_dir: str | None,
) -> dict[str, Any]:
"""Run a script synchronously.
Args:
command: Command to execute
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
try:
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
capture_output=True,
text=True,
timeout=timeout,
)
execution_time = time.time() - start_time
return {
"exit_code": result.returncode,
"stdout": result.stdout[:10000], # Limit output size
"stderr": result.stderr[:10000],
"execution_time": execution_time,
}
except subprocess.TimeoutExpired:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Script timed out after {timeout} seconds",
"execution_time": execution_time,
}
except Exception as e:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": str(e),
"execution_time": execution_time,
}
# Script management endpoints
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
command: str = Field(..., description="Command to execute", min_length=1)
label: str | None = Field(default=None, description="User-friendly label")
description: str = Field(default="", description="Script description")
icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')")
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
def _validate_script_name(name: str) -> None:
"""Validate script name.
Args:
name: Script name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name cannot be empty",
)
if not re.match(r"^[a-zA-Z0-9_]+$", name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must contain only alphanumeric characters and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must be 64 characters or less",
)
@router.post("/create/{script_name}")
async def create_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new script.
Args:
script_name: Name for the new script (alphanumeric + underscore only).
request: Script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script already exists or name is invalid.
"""
# Validate name
_validate_script_name(script_name)
# Check if script already exists
if script_name in settings.scripts:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Add to config file and in-memory
try:
config_manager.add_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to add script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' created successfully")
return {"success": True, "script": script_name}
@router.put("/update/{script_name}")
async def update_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing script.
Args:
script_name: Name of the script to update.
request: Updated script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Update config file and in-memory
try:
config_manager.update_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to update script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' updated successfully")
return {"success": True, "script": script_name}
@router.delete("/delete/{script_name}")
async def delete_script(
script_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a script.
Args:
script_name: Name of the script to delete.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found",
)
# Delete from config file and in-memory
try:
config_manager.delete_script(script_name)
except Exception as e:
logger.error(f"Failed to delete script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' deleted successfully")
return {"success": True, "script": script_name}