"""Callback management 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 CallbackConfig, settings from ..config_manager import config_manager router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) logger = logging.getLogger(__name__) # Dedicated executor for callback/subprocess execution _callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") class CallbackInfo(BaseModel): """Information about a configured callback.""" name: str command: str timeout: int working_dir: str | None = None shell: bool class CallbackCreateRequest(BaseModel): """Request model for creating or updating a callback.""" command: str = Field(..., description="Command to execute", min_length=1) 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") class CallbackExecuteResponse(BaseModel): """Response model for callback execution.""" success: bool callback: str exit_code: int | None = None stdout: str = "" stderr: str = "" error: str | None = None execution_time: float | None = None def _validate_callback_name(name: str) -> None: """Validate callback name. Args: name: Callback name to validate. Raises: HTTPException: If name is invalid. """ # All available callback events valid_names = { "on_play", "on_pause", "on_stop", "on_next", "on_previous", "on_volume", "on_mute", "on_seek", "on_turn_on", "on_turn_off", "on_toggle", } if name not in valid_names: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Callback name must be one of: {', '.join(sorted(valid_names))}", ) @router.get("/list") async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]: """List all configured callbacks. Returns: List of configured callbacks. """ return [ CallbackInfo( name=name, command=config.command, timeout=config.timeout, working_dir=config.working_dir, shell=config.shell, ) for name, config in settings.callbacks.items() ] @router.post("/execute/{callback_name}") async def execute_callback( callback_name: str, _: str = Depends(verify_token), ) -> CallbackExecuteResponse: """Execute a callback for debugging purposes. Args: callback_name: Name of the callback to execute Returns: Execution result including stdout, stderr, and exit code """ # Validate callback name _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Callback '{callback_name}' not found. Use /api/callbacks/list to see configured callbacks.", ) callback_config = settings.callbacks[callback_name] logger.info(f"Executing callback for debugging: {callback_name}") try: # Execute in dedicated thread pool to not block the default executor loop = asyncio.get_event_loop() result = await loop.run_in_executor( _callback_executor, lambda: _run_callback( command=callback_config.command, timeout=callback_config.timeout, shell=callback_config.shell, working_dir=callback_config.working_dir, ), ) return CallbackExecuteResponse( success=result["exit_code"] == 0, callback=callback_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"Callback execution error: {e}") return CallbackExecuteResponse( success=False, callback=callback_name, error=str(e), ) def _run_callback( command: str, timeout: int, shell: bool, working_dir: str | None, ) -> dict[str, Any]: """Run a callback 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"Callback 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, } @router.post("/create/{callback_name}") async def create_callback( callback_name: str, request: CallbackCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Create a new callback. Args: callback_name: Callback event name (on_turn_on, on_turn_off, on_toggle). request: Callback configuration. Returns: Success response with callback name. Raises: HTTPException: If callback already exists or name is invalid. """ # Validate name _validate_callback_name(callback_name) # Check if callback already exists if callback_name in settings.callbacks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.", ) # Create callback config callback_config = CallbackConfig(**request.model_dump()) # Add to config file and in-memory try: config_manager.add_callback(callback_name, callback_config) except Exception as e: logger.error(f"Failed to add callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to add callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' created successfully") return {"success": True, "callback": callback_name} @router.put("/update/{callback_name}") async def update_callback( callback_name: str, request: CallbackCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Update an existing callback. Args: callback_name: Callback event name. request: Updated callback configuration. Returns: Success response with callback name. Raises: HTTPException: If callback does not exist. """ # Validate name _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.", ) # Create updated callback config callback_config = CallbackConfig(**request.model_dump()) # Update config file and in-memory try: config_manager.update_callback(callback_name, callback_config) except Exception as e: logger.error(f"Failed to update callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' updated successfully") return {"success": True, "callback": callback_name} @router.delete("/delete/{callback_name}") async def delete_callback( callback_name: str, _: str = Depends(verify_token), ) -> dict[str, Any]: """Delete a callback. Args: callback_name: Callback event name. Returns: Success response with callback name. Raises: HTTPException: If callback does not exist. """ # Validate name _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Callback '{callback_name}' not found", ) # Delete from config file and in-memory try: config_manager.delete_callback(callback_name) except Exception as e: logger.error(f"Failed to delete callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' deleted successfully") return {"success": True, "callback": callback_name}