diff --git a/media_server/config.py b/media_server/config.py index 9dcd574..f7a0a87 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -26,6 +26,26 @@ class CallbackConfig(BaseModel): shell: bool = Field(default=True, description="Run command in shell") +class ScriptParameterConfig(BaseModel): + """Configuration for a script parameter.""" + + type: str = Field( + ..., + description="Parameter type: string, integer, float, boolean, select", + pattern=r"^(string|integer|float|boolean|select)$", + ) + description: str = Field(default="", description="Parameter description") + required: bool = Field(default=False, description="Whether the parameter is required") + default: Optional[str | int | float | bool] = Field( + default=None, description="Default value if not provided" + ) + min: Optional[float] = Field(default=None, description="Minimum value (numeric types only)") + max: Optional[float] = Field(default=None, description="Maximum value (numeric types only)") + options: Optional[list[str]] = Field( + default=None, description="Allowed values (select type only)" + ) + + class ScriptConfig(BaseModel): """Configuration for a custom script.""" @@ -36,6 +56,9 @@ class ScriptConfig(BaseModel): timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) working_dir: Optional[str] = Field(default=None, description="Working directory") shell: bool = Field(default=True, description="Run command in shell") + parameters: dict[str, ScriptParameterConfig] = Field( + default_factory=dict, description="Named parameters with type and validation rules" + ) class LinkConfig(BaseModel): diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py index 98ed407..c2b8c76 100644 --- a/media_server/routes/scripts.py +++ b/media_server/routes/scripts.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ..auth import verify_token -from ..config import ScriptConfig, settings +from ..config import ScriptConfig, ScriptParameterConfig, settings from ..config_manager import config_manager from ..services.websocket_manager import ws_manager @@ -24,9 +24,11 @@ logger = logging.getLogger(__name__) class ScriptExecuteRequest(BaseModel): - """Request model for script execution with optional arguments.""" + """Request model for script execution with optional parameters.""" - args: list[str] = Field(default_factory=list, description="Additional arguments") + params: dict[str, str | int | float | bool] = Field( + default_factory=dict, description="Named parameters (validated against script schema)" + ) class ScriptExecuteResponse(BaseModel): @@ -41,6 +43,18 @@ class ScriptExecuteResponse(BaseModel): execution_time: float | None = None +class ScriptParameterInfo(BaseModel): + """Information about a script parameter.""" + + type: str + description: str = "" + required: bool = False + default: str | int | float | bool | None = None + min: float | None = None + max: float | None = None + options: list[str] | None = None + + class ScriptInfo(BaseModel): """Information about an available script.""" @@ -50,6 +64,7 @@ class ScriptInfo(BaseModel): description: str icon: str | None = None timeout: int + parameters: dict[str, ScriptParameterInfo] = Field(default_factory=dict) @router.get("/list") @@ -67,11 +82,126 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]: description=config.description, icon=config.icon, timeout=config.timeout, + parameters={ + pname: ScriptParameterInfo(**pconfig.model_dump()) + for pname, pconfig in config.parameters.items() + }, ) for name, config in settings.scripts.items() ] +def _validate_params( + params: dict[str, str | int | float | bool], + param_defs: dict[str, ScriptParameterConfig], +) -> dict[str, str]: + """Validate parameters against script schema and return env vars. + + Args: + params: User-supplied parameter values. + param_defs: Parameter definitions from script config. + + Returns: + Dict of environment variables (SCRIPT_PARAM_ -> str value). + + Raises: + HTTPException: On validation failure. + """ + # Reject unknown parameters + unknown = set(params.keys()) - set(param_defs.keys()) + if unknown: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown parameters: {', '.join(sorted(unknown))}", + ) + + env_vars: dict[str, str] = {} + + for pname, pdef in param_defs.items(): + value = params.get(pname) + + # Apply default if missing + if value is None and pdef.default is not None: + value = pdef.default + + # Check required + if value is None: + if pdef.required: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Required parameter '{pname}' is missing", + ) + continue + + # Type validation and coercion + if pdef.type == "integer": + try: + value = int(value) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be an integer, got: {value!r}", + ) + if pdef.min is not None and value < pdef.min: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}", + ) + if pdef.max is not None and value > pdef.max: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}", + ) + elif pdef.type == "float": + try: + value = float(value) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be a number, got: {value!r}", + ) + if pdef.min is not None and value < pdef.min: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}", + ) + if pdef.max is not None and value > pdef.max: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}", + ) + elif pdef.type == "boolean": + if isinstance(value, str): + if value.lower() in ("true", "1", "yes"): + value = True + elif value.lower() in ("false", "0", "no"): + value = False + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be a boolean, got: {value!r}", + ) + elif not isinstance(value, bool): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be a boolean, got: {value!r}", + ) + elif pdef.type == "select": + value = str(value) + if pdef.options and value not in pdef.options: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' must be one of {pdef.options}, got: {value!r}", + ) + else: + # string — just convert to str + value = str(value) + + env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value) + + return env_vars + + @router.post("/execute/{script_name}") async def execute_script( script_name: str, @@ -82,7 +212,7 @@ async def execute_script( Args: script_name: Name of the script to execute (must be defined in config) - request: Optional arguments to pass to the script + request: Optional parameters to pass to the script Returns: Execution result including stdout, stderr, and exit code @@ -94,26 +224,24 @@ async def execute_script( 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 [] + params = request.params if request else {} + + # Validate parameters and build env vars + extra_env = _validate_params(params, script_config.parameters) 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, + command=script_config.command, timeout=script_config.timeout, shell=script_config.shell, working_dir=script_config.working_dir, + extra_env=extra_env, ), ) @@ -140,6 +268,7 @@ def _run_script( timeout: int, shell: bool, working_dir: str | None, + extra_env: dict[str, str] | None = None, ) -> dict[str, Any]: """Run a script synchronously. @@ -148,11 +277,16 @@ def _run_script( timeout: Timeout in seconds shell: Whether to run in shell working_dir: Working directory + extra_env: Additional environment variables (e.g. SCRIPT_PARAM_*) Returns: Dict with exit_code, stdout, stderr, execution_time """ start_time = time.time() + env = None + if extra_env: + import os + env = {**os.environ, **extra_env} try: result = subprocess.run( command, @@ -161,6 +295,7 @@ def _run_script( capture_output=True, text=True, timeout=timeout, + env=env, ) execution_time = time.time() - start_time return { @@ -190,6 +325,24 @@ def _run_script( # Script management endpoints +class ScriptParameterCreateRequest(BaseModel): + """Request model for a script parameter definition.""" + + type: str = Field( + ..., description="Parameter type: string, integer, float, boolean, select" + ) + description: str = Field(default="", description="Parameter description") + required: bool = Field(default=False, description="Whether the parameter is required") + default: str | int | float | bool | None = Field( + default=None, description="Default value if not provided" + ) + min: float | None = Field(default=None, description="Minimum value (numeric types only)") + max: float | None = Field(default=None, description="Maximum value (numeric types only)") + options: list[str] | None = Field( + default=None, description="Allowed values (select type only)" + ) + + class ScriptCreateRequest(BaseModel): """Request model for creating or updating a script.""" @@ -200,6 +353,60 @@ class ScriptCreateRequest(BaseModel): 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") + parameters: dict[str, ScriptParameterCreateRequest] = Field( + default_factory=dict, description="Named parameters with type and validation rules" + ) + + +def _validate_parameter_definitions( + parameters: dict[str, ScriptParameterCreateRequest], +) -> None: + """Validate parameter definitions are well-formed. + + Args: + parameters: Parameter definitions to validate. + + Raises: + HTTPException: If any definition is invalid. + """ + valid_types = {"string", "integer", "float", "boolean", "select"} + + for pname, pdef in parameters.items(): + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", pname): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Parameter name '{pname}' must start with a letter" + " and contain only alphanumeric characters and underscores" + ), + ) + + if pdef.type not in valid_types: + allowed = ", ".join(sorted(valid_types)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' has invalid type '{pdef.type}'. Must be one of: {allowed}", + ) + + if pdef.type == "select": + if not pdef.options or len(pdef.options) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}' of type 'select' must have a non-empty 'options' list", + ) + + if pdef.type not in ("integer", "float"): + if pdef.min is not None or pdef.max is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}': 'min'/'max' are only valid for integer/float types", + ) + + if pdef.min is not None and pdef.max is not None and pdef.min > pdef.max: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parameter '{pname}': 'min' ({pdef.min}) must be <= 'max' ({pdef.max})", + ) def _validate_script_name(name: str) -> None: @@ -258,8 +465,16 @@ async def create_script( 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()) + # Validate parameter definitions + _validate_parameter_definitions(request.parameters) + + # Build ScriptConfig with ScriptParameterConfig instances + data = request.model_dump() + data["parameters"] = { + pname: ScriptParameterConfig(**pdef) + for pname, pdef in data.get("parameters", {}).items() + } + script_config = ScriptConfig(**data) # Add to config file and in-memory try: @@ -306,8 +521,16 @@ async def update_script( 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()) + # Validate parameter definitions + _validate_parameter_definitions(request.parameters) + + # Build ScriptConfig with ScriptParameterConfig instances + data = request.model_dump() + data["parameters"] = { + pname: ScriptParameterConfig(**pdef) + for pname, pdef in data.get("parameters", {}).items() + } + script_config = ScriptConfig(**data) # Update config file and in-memory try: diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 0c862e0..e33b109 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -1699,6 +1699,11 @@ dialog { margin: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); animation: dialogIn 0.25s ease-out; + overflow: visible; +} + +dialog form { + overflow: visible; } dialog.dialog-closing { @@ -1871,6 +1876,319 @@ dialog.dialog-closing::backdrop { background: var(--border); } +/* Parameters editor (CRUD dialog) */ +.params-section { + margin-top: 0.5rem; +} + +.params-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.btn-small { + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: background 0.2s; +} + +.btn-small:hover { + background: var(--border); +} + +.param-row { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: var(--bg-tertiary); +} + +.param-row-header { + display: flex; + gap: 0.375rem; + align-items: center; +} + +.param-row-header .param-name { + flex: 1; + min-width: 0; +} + +.param-row-header .param-type { + width: 100px; + flex-shrink: 0; +} + +.param-required-label { + display: flex !important; + align-items: center; + gap: 0.125rem; + margin-bottom: 0 !important; + cursor: pointer; + color: var(--accent); + font-weight: 700; + font-size: 1rem; + flex-shrink: 0; +} + +.param-required-label input { + width: auto !important; + margin: 0 !important; +} + +.param-remove-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0 0.25rem; + flex-shrink: 0; + transition: color 0.2s; +} + +.param-remove-btn:hover { + color: var(--error); +} + +.param-row-details { + margin-top: 0.375rem; +} + +.param-row-details .param-description { + width: 100%; + margin-bottom: 0.25rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.param-row-extra { + display: flex; + gap: 0.375rem; +} + +.param-row-extra input { + flex: 1; + min-width: 0; +} + +.param-row-header input, +.param-row-header select, +.param-row-details input, +.param-row-extra input { + padding: 0.35rem 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.8rem; +} + +.param-row-header input:focus, +.param-row-header select:focus, +.param-row-details input:focus, +.param-row-extra input:focus { + outline: none; + border-color: var(--accent); +} + +/* Parameter hint in execution dialog */ +.param-hint { + display: block; + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 0.125rem; +} + +/* ── Icon Select ──────────────────────────────────────────── */ + +.icon-select-trigger { + display: flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0.35rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: border-color 0.15s; + text-align: left; + font-family: inherit; + box-sizing: border-box; +} + +/* Match dialog input height when inside a dialog */ +.dialog-body .icon-select-trigger { + padding: 0.5rem; + margin-top: 0.25rem; +} + +/* Compact trigger inside param rows */ +.param-row-header .icon-select-trigger { + padding: 0.3rem 0.4rem; + margin-top: 0; + font-size: 0.75rem; + width: 110px; + flex-shrink: 0; +} + +.icon-select-trigger:hover { + border-color: var(--accent); +} + +.icon-select-trigger-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.icon-select-trigger-icon svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.param-row-header .icon-select-trigger-icon svg { + width: 14px; + height: 14px; +} + +.icon-select-trigger-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon-select-trigger-arrow { + flex-shrink: 0; + font-size: 0.8rem; + opacity: 0.5; + margin-left: auto; +} + +.icon-select-popup { + position: absolute; + z-index: 100; + max-height: 260px; + overflow: hidden; + opacity: 0; + transform: translateY(-4px) scale(0.97); + transition: opacity 0.12s ease-out, transform 0.12s cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; + box-sizing: border-box; +} + +.icon-select-popup.open { + opacity: 1; + transform: translateY(0) scale(1); + overflow-y: auto; + pointer-events: auto; +} + + + +.icon-select-grid { + display: grid; + grid-auto-rows: 1fr; + gap: 6px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); +} + +.icon-select-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 6px 4px; + border: 2px solid transparent; + border-radius: 6px; + background: var(--bg-tertiary); + cursor: pointer; + transition: border-color 0.15s, background 0.15s, transform 0.1s; + text-align: center; +} + +.icon-select-cell:hover { + border-color: var(--accent); + transform: scale(1.03); +} + +.icon-select-cell.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--bg-tertiary)); +} + +.icon-select-cell-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.icon-select-cell-icon svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.icon-select-cell.active .icon-select-cell-icon svg { + fill: var(--accent); +} + +.icon-select-cell-label { + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; +} + +.icon-select-cell-desc { + font-size: 0.7rem; + opacity: 0.6; + line-height: 1.3; +} + +/* Horizontal layout for single-column grids (e.g. audio device list) */ +.icon-select-grid--horizontal .icon-select-cell { + flex-direction: row; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + text-align: left; +} + +.icon-select-grid--horizontal .icon-select-cell-icon svg { + width: 16px; + height: 16px; +} + +.icon-select-grid--horizontal .icon-select-cell-label { + font-size: 0.8rem; +} + +@media (max-width: 480px) { + .icon-select-cell-desc { display: none; } + .icon-select-cell { padding: 6px 4px; } + .icon-select-grid { gap: 4px; padding: 4px; } +} + .empty-state { text-align: center; padding: 2rem; diff --git a/media_server/static/index.html b/media_server/static/index.html index 5b560b7..6631545 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -465,6 +465,14 @@ Timeout (seconds) + +
+
+ Parameters + +
+
+