Files
media-player-server/media_server/routes/callbacks.py
T
alexei.dolgolyov 5439af1955
Lint & Test / test (push) Failing after 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Successful in 59s
Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
- Add Gitea Actions workflows: test.yml (lint + test on push/PR) and
  release.yml (build + NSIS installer + upload on v* tags)
- Add NSIS installer with optional desktop shortcut and auto-start
- Add esbuild bundler: ES module migration with IIFE bundle output
- Add build-dist-windows.sh for cross-building Windows distribution
- Fix all ruff lint errors (import sorting, unused imports, line length)
- Remove redundant scripts (start-server.bat, stop-server.bat,
  start-server-background.vbs)
- Update CLAUDE.md with CI/CD and release documentation
2026-03-23 02:01:28 +03:00

349 lines
9.9 KiB
Python

"""Callback management API endpoints."""
import asyncio
import logging
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."
f" 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."
f" 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}