diff --git a/media_server/config_manager.py b/media_server/config_manager.py index 5a59af5..51b1ab3 100644 --- a/media_server/config_manager.py +++ b/media_server/config_manager.py @@ -8,7 +8,7 @@ from typing import Optional import yaml -from .config import ScriptConfig, settings +from .config import CallbackConfig, ScriptConfig, settings logger = logging.getLogger(__name__) @@ -171,6 +171,114 @@ class ConfigManager: logger.info(f"Script '{name}' deleted from config") + def add_callback(self, name: str, config: CallbackConfig) -> None: + """Add a new callback to config. + + Args: + name: Callback name (must be unique). + config: Callback configuration. + + Raises: + ValueError: If callback already exists. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + data = {} + else: + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if callback already exists + if "callbacks" in data and name in data["callbacks"]: + raise ValueError(f"Callback '{name}' already exists") + + # Add callback + if "callbacks" not in data: + data["callbacks"] = {} + data["callbacks"][name] = config.model_dump(exclude_none=True) + + # Write YAML + self._config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.callbacks[name] = config + + logger.info(f"Callback '{name}' added to config") + + def update_callback(self, name: str, config: CallbackConfig) -> None: + """Update an existing callback. + + Args: + name: Callback name. + config: New callback configuration. + + Raises: + ValueError: If callback does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if callback exists + if "callbacks" not in data or name not in data["callbacks"]: + raise ValueError(f"Callback '{name}' does not exist") + + # Update callback + data["callbacks"][name] = config.model_dump(exclude_none=True) + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.callbacks[name] = config + + logger.info(f"Callback '{name}' updated in config") + + def delete_callback(self, name: str) -> None: + """Delete a callback from config. + + Args: + name: Callback name. + + Raises: + ValueError: If callback does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if callback exists + if "callbacks" not in data or name not in data["callbacks"]: + raise ValueError(f"Callback '{name}' does not exist") + + # Delete callback + del data["callbacks"][name] + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + if name in settings.callbacks: + del settings.callbacks[name] + + logger.info(f"Callback '{name}' deleted from config") + # Global config manager instance config_manager = ConfigManager() diff --git a/media_server/main.py b/media_server/main.py index c9623bc..4f6611c 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles from . import __version__ from .auth import get_token_label, token_label_var from .config import settings, generate_default_config, get_config_dir -from .routes import audio_router, health_router, media_router, scripts_router +from .routes import audio_router, callbacks_router, health_router, media_router, scripts_router from .services import get_media_controller from .services.websocket_manager import ws_manager @@ -110,6 +110,7 @@ def create_app() -> FastAPI: # Register routers app.include_router(audio_router) + app.include_router(callbacks_router) app.include_router(health_router) app.include_router(media_router) app.include_router(scripts_router) diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index 1fe7168..c386e40 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -1,8 +1,9 @@ """API route modules.""" from .audio import router as audio_router +from .callbacks import router as callbacks_router from .health import router as health_router from .media import router as media_router from .scripts import router as scripts_router -__all__ = ["audio_router", "health_router", "media_router", "scripts_router"] +__all__ = ["audio_router", "callbacks_router", "health_router", "media_router", "scripts_router"] diff --git a/media_server/routes/callbacks.py b/media_server/routes/callbacks.py new file mode 100644 index 0000000..87aec18 --- /dev/null +++ b/media_server/routes/callbacks.py @@ -0,0 +1,214 @@ +"""Callback management API endpoints.""" + +import logging +import re +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__) + + +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") + + +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("/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} diff --git a/media_server/static/index.html b/media_server/static/index.html index ab30f12..6103c3a 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -18,6 +18,19 @@ --error: #e74c3c; } + :root[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-tertiary: #e8e8e8; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --text-muted: #888888; + --accent: #1db954; + --accent-hover: #1ed760; + --border: #d0d0d0; + --error: #e74c3c; + } + * { margin: 0; padding: 0; @@ -71,6 +84,30 @@ background: var(--accent); } + .theme-toggle { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + width: 40px; + height: 40px; + } + + .theme-toggle:hover { + background: var(--border); + } + + .theme-toggle svg { + width: 20px; + height: 20px; + fill: var(--text-primary); + } + .player-container { background: var(--bg-secondary); border-radius: 12px; @@ -352,7 +389,7 @@ } .add-script-btn { - padding: 0.5rem 1rem; + padding: 0.5rem 1.5rem; border-radius: 6px; background: var(--accent); border: none; @@ -361,6 +398,7 @@ font-size: 0.875rem; font-weight: 600; transition: background 0.2s; + min-width: 140px; } .add-script-btn:hover { @@ -459,7 +497,8 @@ } .dialog-body input, - .dialog-body textarea { + .dialog-body textarea, + .dialog-body select { display: block; width: 100%; padding: 0.5rem; @@ -478,7 +517,8 @@ } .dialog-body input:focus, - .dialog-body textarea:focus { + .dialog-body textarea:focus, + .dialog-body select:focus { outline: none; border-color: var(--accent); } @@ -723,9 +763,19 @@
+ Callbacks are scripts triggered automatically by media control events (play, pause, volume, etc.) +
+| Event | +Command | +Timeout | +Actions | +
|---|---|---|---|
| No callbacks configured. Click "Add Callback" to create one. | +|||