API docs tweaks
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 3 Nov 2025 15:10:36 +0000 (16:10 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 3 Nov 2025 15:10:36 +0000 (16:10 +0100)
CLAUDE.md
music_assistant/controllers/webserver.py
music_assistant/helpers/api_docs.py
music_assistant/helpers/resources/api_docs.html
music_assistant/helpers/resources/redoc_ui.html [deleted file]
music_assistant/helpers/resources/swagger_ui.html
pyproject.toml

index a62f29628e0dedb1b71fa7a0df1393bf549397ae..16d846a4667c45a4deca12d0f0eb75b0a4b0f34d 100644 (file)
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -105,6 +105,62 @@ These demo/example implementations have a lot of docstrings and comments to help
 - Docker-based deployment (not standalone pip package)
 - VS Code launch configurations provided for debugging
 
+## Code Style Guidelines
+
+### Docstring Format
+
+Music Assistant uses **Sphinx-style docstrings** with `:param:` syntax for documenting function parameters. This is the standard format used throughout the codebase.
+
+**Correct format:**
+```python
+def my_function(param1: str, param2: int, param3: bool = False) -> str:
+    """Brief one-line description of the function.
+
+    Optional longer description providing more context about what the function does,
+    why it exists, and any important implementation details.
+
+    :param param1: Description of what param1 is used for.
+    :param param2: Description of what param2 is used for.
+    :param param3: Description of what param3 is used for.
+    """
+```
+
+**Key points:**
+- Use `:param param_name: description` format for all parameters
+- Brief summary on first line, followed by blank line
+- Optional detailed description before parameters section
+- No need to document types in docstring (use type hints instead)
+- No need to document return types in docstring (use type hints instead)
+
+**Incorrect formats to avoid:**
+```python
+# ❌ Bullet-style (being phased out)
+"""Function description.
+
+- param1: Description
+- param2: Description
+"""
+
+# ❌ Google-style
+"""Function description.
+
+Args:
+    param1: Description
+    param2: Description
+"""
+```
+
+**For simple functions**, a single-line docstring is acceptable:
+```python
+def get_item(self, item_id: str) -> Item:
+    """Get an item by its ID."""
+```
+
+**Enforcement:**
+- Ruff with pydocstyle rules enforces basic docstring structure
+- Pre-commit hooks check docstring format
+- The API documentation generator parses Sphinx-style docstrings for the web interface
+
 ## Branching Strategy
 
 ### Branch Structure
index 30d8ca9efb913a72c5ebc97056adc4bbadd07b0f..1b2570c6ad36202af4c2cc59e11b4080dfa0e0ca 100644 (file)
@@ -33,7 +33,11 @@ from music_assistant_models.errors import InvalidCommand
 
 from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT, VERBOSE_LOG_LEVEL
 from music_assistant.helpers.api import APICommandHandler, parse_arguments
-from music_assistant.helpers.api_docs import generate_openapi_spec
+from music_assistant.helpers.api_docs import (
+    generate_commands_reference,
+    generate_openapi_spec,
+    generate_schemas_reference,
+)
 from music_assistant.helpers.audio import get_preview_stream
 from music_assistant.helpers.json import json_dumps, json_loads
 from music_assistant.helpers.util import get_ip_addresses
@@ -157,9 +161,14 @@ class WebserverController(CoreController):
         routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
         # add api documentation
         routes.append(("GET", "/api-docs", self._handle_api_intro))
+        routes.append(("GET", "/api-docs/", self._handle_api_intro))
+        routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
+        routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
+        routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
+        routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
         routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
         routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
-        routes.append(("GET", "/api-docs/redoc", self._handle_redoc_ui))
+        routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
         # start the webserver
         all_ip_addresses = await get_ip_addresses()
         default_publish_ip = all_ip_addresses[0]
@@ -265,14 +274,23 @@ class WebserverController(CoreController):
             error = f"Invalid Command: {command_msg.command}"
             self.logger.error("Unhandled JSONRPC API error: %s", error)
             return web.Response(status=400, text=error)
-        args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
-        result = handler.target(**args)
-        if hasattr(result, "__anext__"):
-            # handle async generator (for really large listings)
-            result = [item async for item in result]
-        elif asyncio.iscoroutine(result):
-            result = await result
-        return web.json_response(result, dumps=json_dumps)
+
+        try:
+            args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
+            result = handler.target(**args)
+            if hasattr(result, "__anext__"):
+                # handle async generator (for really large listings)
+                result = [item async for item in result]
+            elif asyncio.iscoroutine(result):
+                result = await result
+            return web.json_response(result, dumps=json_dumps)
+        except Exception as e:
+            # Return clean error message without stacktrace
+            error_type = type(e).__name__
+            error_msg = str(e)
+            error = f"{error_type}: {error_msg}"
+            self.logger.error("Error executing command %s: %s", command_msg.command, error)
+            return web.Response(status=500, text=error)
 
     async def _handle_application_log(self, request: web.Request) -> web.Response:
         """Handle request to get the application log."""
@@ -302,6 +320,16 @@ class WebserverController(CoreController):
         )
         return web.json_response(spec)
 
+    async def _handle_commands_reference(self, request: web.Request) -> web.Response:
+        """Handle request for commands reference page (generated on-the-fly)."""
+        html = generate_commands_reference(self.mass.command_handlers, server_url=self.base_url)
+        return web.Response(text=html, content_type="text/html")
+
+    async def _handle_schemas_reference(self, request: web.Request) -> web.Response:
+        """Handle request for schemas reference page (generated on-the-fly)."""
+        html = generate_schemas_reference(self.mass.command_handlers)
+        return web.Response(text=html, content_type="text/html")
+
     async def _handle_swagger_ui(self, request: web.Request) -> web.Response:
         """Handle request for Swagger UI."""
         swagger_html_path = os.path.join(
@@ -309,13 +337,6 @@ class WebserverController(CoreController):
         )
         return await self._server.serve_static(swagger_html_path, request)
 
-    async def _handle_redoc_ui(self, request: web.Request) -> web.Response:
-        """Handle request for ReDoc UI."""
-        redoc_html_path = os.path.join(
-            os.path.dirname(__file__), "..", "helpers", "resources", "redoc_ui.html"
-        )
-        return await self._server.serve_static(redoc_html_path, request)
-
 
 class WebsocketClientHandler:
     """Handle an active websocket client connection."""
index 0a6a9d9bbb161c047cc069efcbbd805f2ca27a97..1e561b1a5570570ea52c83c55103326aac42f3fa 100644 (file)
@@ -74,12 +74,18 @@ def _get_type_schema(  # noqa: PLR0911, PLR0915
 
         # Check if it looks like a simple class name (no special chars, starts with uppercase)
         # Examples: "PlayerType", "DeviceInfo", "PlaybackState"
-        if type_hint.isidentifier() and type_hint[0].isupper():
+        # Exclude generic types like "Any", "Union", "Optional", etc.
+        excluded_types = {"Any", "Union", "Optional", "List", "Dict", "Tuple", "Set"}
+        if type_hint.isidentifier() and type_hint[0].isupper() and type_hint not in excluded_types:
             # Create a schema reference for this type
             if type_hint not in definitions:
                 definitions[type_hint] = {"type": "object"}
             return {"$ref": f"#/components/schemas/{type_hint}"}
 
+        # If it's "Any", return generic object without creating a schema
+        if type_hint == "Any":
+            return {"type": "object"}
+
         # For complex type expressions like "str | None", "list[str]", return generic object
         return {"type": "object"}
 
@@ -338,6 +344,21 @@ def _parse_docstring(  # noqa: PLR0915
             current_param = None
             continue
 
+        # Detect bullet-style params even without explicit section header
+        # Format: "- param_name: description"
+        if stripped.startswith("- ") and ":" in stripped:
+            # This is likely a bullet-style parameter
+            current_section = "params"
+            content = stripped[2:]  # Remove "- "
+            parts = content.split(":", 1)
+            param_name = parts[0].strip()
+            desc_part = parts[1].strip() if len(parts) > 1 else ""
+            if param_name and not param_name.startswith(("return", "yield", "raise")):
+                current_param = param_name
+                if desc_part:
+                    param_descriptions[current_param] = desc_part
+            continue
+
         # In params section, detect param lines (indented or starting with name)
         if current_section == "params" and stripped:
             # Google/NumPy style: "param_name: description" or "param_name (type): description"
@@ -394,97 +415,95 @@ def generate_openapi_spec(
     server_url: str = "http://localhost:8095",
     version: str = "1.0.0",
 ) -> dict[str, Any]:
-    """Generate OpenAPI 3.0 specification from API command handlers."""
-    definitions: dict[str, Any] = {}
-    paths: dict[str, Any] = {}
-
-    for command, handler in sorted(command_handlers.items()):
-        # Parse docstring
-        summary, description, param_descriptions = _parse_docstring(handler.target)
+    """Generate simplified OpenAPI 3.0 specification focusing on data models.
 
-        # Build request body
-        request_body_properties = {}
-        request_body_required = []
+    This spec documents the single /api endpoint and all data models/schemas.
+    For detailed command documentation, see the Commands Reference page.
+    """
+    definitions: dict[str, Any] = {}
 
-        for param_name, param in handler.signature.parameters.items():
+    # Build all schemas from command handlers (this populates definitions)
+    for handler in command_handlers.values():
+        # Build parameter schemas
+        for param_name in handler.signature.parameters:
             if param_name == "self":
                 continue
-
             param_type = handler.type_hints.get(param_name, Any)
-            param_schema = _get_type_schema(param_type, definitions)
-            param_description = param_descriptions.get(param_name, "")
-
-            # Check if parameter is required
-            is_required = param.default is inspect.Parameter.empty
-
-            # Add default value if present
-            if not is_required:
-                # Try to serialize the default value
-                try:
-                    if param.default is None:
-                        param_schema["default"] = None
-                    elif isinstance(param.default, (str, int, float, bool)):
-                        param_schema["default"] = param.default
-                    elif isinstance(param.default, Enum):
-                        param_schema["default"] = param.default.value
-                    elif isinstance(param.default, (list, dict)):
-                        param_schema["default"] = param.default
-                except Exception:  # noqa: S110
-                    # If we can't serialize it, just skip the default
-                    pass
-
-            # Add to request body properties
-            request_body_properties[param_name] = {
-                **param_schema,
-                "description": param_description,
-            }
-            if is_required:
-                request_body_required.append(param_name)
+            # Skip Any types as they don't provide useful schema information
+            if param_type is not Any and str(param_type) != "typing.Any":
+                _get_type_schema(param_type, definitions)
 
-        # Build response
+        # Build return type schema
         return_type = handler.type_hints.get("return", Any)
-        response_schema = _get_type_schema(return_type, definitions)
+        # Skip Any types as they don't provide useful schema information
+        if return_type is not Any and str(return_type) != "typing.Any":
+            _get_type_schema(return_type, definitions)
 
-        # Build path item
-        path = f"/{command}"
-        paths[path] = {
+    # Build a single /api endpoint with generic request/response
+    paths = {
+        "/api": {
             "post": {
-                "summary": summary or command,
-                "description": description,
-                "operationId": command.replace("/", "_"),
-                "tags": [command.split("/")[0]] if "/" in command else ["general"],
+                "summary": "Execute API command",
+                "description": (
+                    "Execute any Music Assistant API command.\n\n"
+                    "See the **Commands Reference** page for a complete list of available "
+                    "commands with examples."
+                ),
+                "operationId": "execute_command",
                 "requestBody": {
                     "required": True,
                     "content": {
                         "application/json": {
                             "schema": {
                                 "type": "object",
-                                "properties": request_body_properties,
-                                **(
-                                    {"required": request_body_required}
-                                    if request_body_required
-                                    else {}
-                                ),
-                            }
+                                "required": ["command"],
+                                "properties": {
+                                    "command": {
+                                        "type": "string",
+                                        "description": (
+                                            "The command to execute (e.g., 'players/all')"
+                                        ),
+                                        "example": "players/all",
+                                    },
+                                    "args": {
+                                        "type": "object",
+                                        "description": "Command arguments (varies by command)",
+                                        "additionalProperties": True,
+                                        "example": {},
+                                    },
+                                },
+                            },
+                            "examples": {
+                                "get_players": {
+                                    "summary": "Get all players",
+                                    "value": {"command": "players/all", "args": {}},
+                                },
+                                "play_media": {
+                                    "summary": "Play media on a player",
+                                    "value": {
+                                        "command": "players/cmd/play",
+                                        "args": {"player_id": "player123"},
+                                    },
+                                },
+                            },
                         }
                     },
-                }
-                if request_body_properties
-                else None,
+                },
                 "responses": {
                     "200": {
-                        "description": "Successful response",
-                        "content": {"application/json": {"schema": response_schema}},
+                        "description": "Successful command execution",
+                        "content": {
+                            "application/json": {
+                                "schema": {"description": "Command result (varies by command)"}
+                            }
+                        },
                     },
-                    "400": {"description": "Bad request - invalid parameters"},
+                    "400": {"description": "Bad request - invalid command or parameters"},
                     "500": {"description": "Internal server error"},
                 },
             }
         }
-
-        # Remove requestBody if empty
-        if not request_body_properties:
-            del paths[path]["post"]["requestBody"]
+    }
 
     # Build OpenAPI spec
     return {
@@ -492,67 +511,13 @@ def generate_openapi_spec(
         "info": {
             "title": "Music Assistant API",
             "version": version,
-            "description": """
-# Music Assistant API Documentation
-
-Music Assistant provides two ways to interact with the API:
-
-## WebSocket API (Recommended)
-- **Endpoint:** `ws://{server}/ws`
-- **Features:**
-  - Full API access to all commands
-  - Real-time event updates
-  - Bi-directional communication
-  - Best for applications that need live updates
-
-### WebSocket Message Format
-Send commands as JSON messages:
-```json
-{
-  "message_id": "unique-id",
-  "command": "command/name",
-  "args": {
-    "param1": "value1",
-    "param2": "value2"
-  }
-}
-```
-
-Receive responses:
-```json
-{
-  "message_id": "unique-id",
-  "result": { ... }
-}
-```
-
-## REST API (Simple)
-- **Endpoint:** `POST /api`
-- **Features:**
-  - Simple HTTP POST requests
-  - JSON request/response
-  - Best for simple, incidental commands
-
-### REST Message Format
-Send POST request to `/api` with JSON body:
-```json
-{
-  "command": "command/name",
-  "args": {
-    "param1": "value1",
-    "param2": "value2"
-  }
-}
-```
-
-Receive JSON response with result.
-
-## Authentication
-Authentication is not yet implemented but will be added in a future release.
-
-## API Commands
-All commands listed below are available via both WebSocket and REST interfaces.
-            """.strip(),
+            "description": (
+                "Music Assistant API provides control over your music library, "
+                "players, and playback.\n\n"
+                "This specification documents the API structure and data models. "
+                "For a complete list of available commands with examples, "
+                "see the Commands Reference page."
+            ),
             "contact": {
                 "name": "Music Assistant",
                 "url": "https://music-assistant.io",
@@ -564,6 +529,1426 @@ All commands listed below are available via both WebSocket and REST interfaces.
     }
 
 
+def _split_union_type(type_str: str) -> list[str]:
+    """Split a union type on | but respect brackets and parentheses.
+
+    This ensures that list[A | B] and (A | B) are not split at the inner |.
+    """
+    parts = []
+    current_part = ""
+    bracket_depth = 0
+    paren_depth = 0
+    i = 0
+    while i < len(type_str):
+        char = type_str[i]
+        if char == "[":
+            bracket_depth += 1
+            current_part += char
+        elif char == "]":
+            bracket_depth -= 1
+            current_part += char
+        elif char == "(":
+            paren_depth += 1
+            current_part += char
+        elif char == ")":
+            paren_depth -= 1
+            current_part += char
+        elif char == "|" and bracket_depth == 0 and paren_depth == 0:
+            # Check if this is a union separator (has space before and after)
+            if (
+                i > 0
+                and i < len(type_str) - 1
+                and type_str[i - 1] == " "
+                and type_str[i + 1] == " "
+            ):
+                parts.append(current_part.strip())
+                current_part = ""
+                i += 1  # Skip the space after |, the loop will handle incrementing i
+            else:
+                current_part += char
+        else:
+            current_part += char
+        i += 1
+    if current_part.strip():
+        parts.append(current_part.strip())
+    return parts
+
+
+def _python_type_to_json_type(type_str: str, _depth: int = 0) -> str:
+    """Convert Python type string to JSON/JavaScript type string.
+
+    Args:
+        type_str: The type string to convert
+        _depth: Internal recursion depth tracker (do not set manually)
+    """
+    import re  # noqa: PLC0415
+
+    # Prevent infinite recursion
+    if _depth > 50:
+        return "any"
+
+    # Remove typing module prefix and class markers
+    type_str = type_str.replace("typing.", "").replace("<class '", "").replace("'>", "")
+
+    # Remove module paths from type names (e.g., "music_assistant.models.Artist" -> "Artist")
+    type_str = re.sub(r"[\w.]+\.(\w+)", r"\1", type_str)
+
+    # Map Python types to JSON types
+    type_mappings = {
+        "str": "string",
+        "int": "integer",
+        "float": "number",
+        "bool": "boolean",
+        "dict": "object",
+        "Dict": "object",
+        "None": "null",
+        "NoneType": "null",
+    }
+
+    # Check for List/list/UniqueList with type parameter BEFORE checking for union types
+    # This is important because list[A | B] contains " | " but should be handled as a list first
+    # We need to match list[...] where the brackets are balanced
+    if type_str.startswith(("list[", "List[", "UniqueList[")):  # codespell:ignore
+        # Find the matching closing bracket
+        bracket_count = 0
+        start_idx = type_str.index("[") + 1
+        end_idx = -1
+        for i in range(start_idx, len(type_str)):
+            if type_str[i] == "[":
+                bracket_count += 1
+            elif type_str[i] == "]":
+                if bracket_count == 0:
+                    end_idx = i
+                    break
+                bracket_count -= 1
+
+        # Check if this is a complete list type (ends with the closing bracket)
+        if end_idx == len(type_str) - 1:
+            inner_type = type_str[start_idx:end_idx].strip()
+            # Recursively convert the inner type
+            inner_json_type = _python_type_to_json_type(inner_type, _depth + 1)
+            # For list[A | B], wrap in parentheses to keep it as one unit
+            # This prevents "Array of A | B" from being split into separate union parts
+            if " | " in inner_json_type:
+                return f"Array of ({inner_json_type})"
+            return f"Array of {inner_json_type}"
+
+    # Handle Union types by splitting on | and recursively processing each part
+    if " | " in type_str:
+        # Use helper to split on | but respect brackets
+        parts = _split_union_type(type_str)
+
+        # Filter out None types
+        parts = [part for part in parts if part != "None"]
+
+        # If splitting didn't help (only one part or same as input), avoid infinite recursion
+        if not parts or (len(parts) == 1 and parts[0] == type_str):
+            # Can't split further, return as-is or "any"
+            return type_str if parts else "any"
+
+        if parts:
+            converted_parts = [_python_type_to_json_type(part, _depth + 1) for part in parts]
+            # Remove duplicates while preserving order
+            seen = set()
+            unique_parts = []
+            for part in converted_parts:
+                if part not in seen:
+                    seen.add(part)
+                    unique_parts.append(part)
+            return " | ".join(unique_parts)
+        return "any"
+
+    # Check for Union/Optional types with brackets
+    if "Union[" in type_str or "Optional[" in type_str:
+        # Extract content from Union[...] or Optional[...]
+        union_match = re.search(r"(?:Union|Optional)\[([^\]]+)\]", type_str)
+        if union_match:
+            inner = union_match.group(1)
+            # Recursively process the union content
+            return _python_type_to_json_type(inner, _depth + 1)
+
+    # Direct mapping for basic types
+    for py_type, json_type in type_mappings.items():
+        if type_str == py_type:
+            return json_type
+
+    # Check if it's a complex type (starts with capital letter)
+    complex_match = re.search(r"^([A-Z][a-zA-Z0-9_]*)$", type_str)
+    if complex_match:
+        return complex_match.group(1)
+
+    # Default to the original string if no mapping found
+    return type_str
+
+
+def _make_type_links(type_str: str, server_url: str, as_list: bool = False) -> str:
+    """Convert type string to HTML with links to schemas reference for complex types.
+
+    Args:
+        type_str: The type string to convert
+        server_url: Base server URL for building links
+        as_list: If True and type contains |, format as "Any of:" bullet list
+    """
+    import re  # noqa: PLC0415
+    from re import Match  # noqa: PLC0415
+
+    # Find all complex types (capitalized words that aren't basic types)
+    def replace_type(match: Match[str]) -> str:
+        type_name = match.group(0)
+        # Check if it's a complex type (starts with capital letter)
+        # Exclude basic types and "Array" (which is used in "Array of Type")
+        excluded = {"Union", "Optional", "List", "Dict", "Array"}
+        if type_name[0].isupper() and type_name not in excluded:
+            # Create link to our schemas reference page
+            schema_url = f"{server_url}/api-docs/schemas#schema-{type_name}"
+            return f'<a href="{schema_url}" class="type-link">{type_name}</a>'
+        return type_name
+
+    # If it's a union type with multiple options and as_list is True, format as bullet list
+    if as_list and " | " in type_str:
+        # Use the bracket/parenthesis-aware splitter
+        parts = _split_union_type(type_str)
+        # Only use list format if there are 3+ options
+        if len(parts) >= 3:
+            html = '<div class="type-union"><span class="type-union-label">Any of:</span><ul>'
+            for part in parts:
+                linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part)
+                html += f"<li>{linked_part}</li>"
+            html += "</ul></div>"
+            return html
+
+    # Replace complex type names with links
+    result: str = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, type_str)
+    return result
+
+
+def generate_commands_reference(  # noqa: PLR0915
+    command_handlers: dict[str, APICommandHandler],
+    server_url: str = "http://localhost:8095",
+) -> str:
+    """Generate HTML commands reference page with all available commands."""
+    import json  # noqa: PLC0415
+
+    # Group commands by category
+    categories: dict[str, list[tuple[str, APICommandHandler]]] = {}
+    for command, handler in sorted(command_handlers.items()):
+        category = command.split("/")[0] if "/" in command else "general"
+        if category not in categories:
+            categories[category] = []
+        categories[category].append((command, handler))
+
+    html = """<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Music Assistant API - Commands Reference</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
+                         Ubuntu, Cantarell, sans-serif;
+            background: #f5f5f5;
+            line-height: 1.6;
+        }
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 1.5rem 2rem;
+            text-align: center;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+        }
+        .header h1 {
+            font-size: 1.8em;
+            margin-bottom: 0.3rem;
+            font-weight: 600;
+        }
+        .header p {
+            font-size: 0.95em;
+            opacity: 0.9;
+        }
+        .nav-container {
+            background: white;
+            padding: 1rem 2rem;
+            box-shadow: 0 2px 5px rgba(0,0,0,0.05);
+            position: sticky;
+            top: 0;
+            z-index: 100;
+            display: flex;
+            flex-direction: column;
+            gap: 1rem;
+        }
+        .search-box input {
+            width: 100%;
+            max-width: 600px;
+            padding: 0.6rem 1rem;
+            font-size: 0.95em;
+            border: 2px solid #ddd;
+            border-radius: 8px;
+            display: block;
+            margin: 0 auto;
+        }
+        .search-box input:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+        .quick-nav {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 0.5rem;
+            justify-content: center;
+            padding-top: 0.5rem;
+            border-top: 1px solid #eee;
+        }
+        .quick-nav a {
+            padding: 0.4rem 1rem;
+            background: #f8f9fa;
+            color: #667eea;
+            text-decoration: none;
+            border-radius: 6px;
+            font-size: 0.9em;
+            transition: all 0.2s;
+        }
+        .quick-nav a:hover {
+            background: #667eea;
+            color: white;
+        }
+        .container {
+            max-width: 1200px;
+            margin: 2rem auto;
+            padding: 0 2rem;
+        }
+        .category {
+            background: white;
+            margin-bottom: 2rem;
+            border-radius: 12px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
+            overflow: hidden;
+        }
+        .category-header {
+            background: #667eea;
+            color: white;
+            padding: 1rem 1.5rem;
+            font-size: 1.2em;
+            font-weight: 600;
+            cursor: pointer;
+            user-select: none;
+        }
+        .category-header:hover {
+            background: #5568d3;
+        }
+        .command {
+            border-bottom: 1px solid #eee;
+        }
+        .command:last-child {
+            border-bottom: none;
+        }
+        .command-header {
+            padding: 1rem 1.5rem;
+            cursor: pointer;
+            user-select: none;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            transition: background 0.2s;
+        }
+        .command-header:hover {
+            background: #f8f9fa;
+        }
+        .command-title {
+            display: flex;
+            flex-direction: column;
+            gap: 0.3rem;
+            flex: 1;
+        }
+        .command-name {
+            font-size: 1.1em;
+            font-weight: 600;
+            color: #667eea;
+            font-family: 'Monaco', 'Courier New', monospace;
+        }
+        .command-summary {
+            font-size: 0.9em;
+            color: #888;
+        }
+        .command-expand-icon {
+            color: #667eea;
+            font-size: 1.2em;
+            transition: transform 0.3s;
+        }
+        .command-expand-icon.expanded {
+            transform: rotate(180deg);
+        }
+        .command-details {
+            padding: 0 1.5rem 1.5rem 1.5rem;
+            display: none;
+        }
+        .command-details.show {
+            display: block;
+        }
+        .command-description {
+            color: #666;
+            margin-bottom: 1rem;
+        }
+        .return-type {
+            background: #e8f5e9;
+            padding: 0.5rem 1rem;
+            margin: 1rem 0;
+            border-radius: 6px;
+            border-left: 3px solid #4caf50;
+        }
+        .return-type-label {
+            font-weight: 600;
+            color: #2e7d32;
+            margin-right: 0.5rem;
+        }
+        .return-type-value {
+            font-family: 'Monaco', 'Courier New', monospace;
+            color: #2e7d32;
+        }
+        .params-section {
+            margin: 1rem 0;
+        }
+        .params-title {
+            font-weight: 600;
+            color: #333;
+            margin-bottom: 0.5rem;
+        }
+        .param {
+            background: #f8f9fa;
+            padding: 0.5rem 1rem;
+            margin: 0.5rem 0;
+            border-radius: 6px;
+            border-left: 3px solid #667eea;
+        }
+        .param-name {
+            font-family: 'Monaco', 'Courier New', monospace;
+            color: #667eea;
+            font-weight: 600;
+        }
+        .param-required {
+            color: #e74c3c;
+            font-size: 0.8em;
+            font-weight: 600;
+            margin-left: 0.5rem;
+        }
+        .param-type {
+            color: #888;
+            font-size: 0.9em;
+            margin-left: 0.5rem;
+        }
+        .param-description {
+            color: #666;
+            margin-top: 0.25rem;
+        }
+        .example {
+            background: #2d2d2d;
+            color: #f8f8f2;
+            padding: 1rem;
+            border-radius: 8px;
+            margin: 1rem 0;
+            overflow-x: auto;
+            position: relative;
+        }
+        .example-title {
+            font-weight: 600;
+            color: #333;
+            margin-bottom: 0.5rem;
+        }
+        .example pre {
+            margin: 0;
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.9em;
+        }
+        .copy-btn {
+            position: absolute;
+            top: 0.5rem;
+            right: 0.5rem;
+            background: #667eea;
+            color: white;
+            border: none;
+            padding: 0.4rem 0.8rem;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 0.8em;
+        }
+        .copy-btn:hover {
+            background: #5568d3;
+        }
+        .hidden {
+            display: none;
+        }
+        .tabs {
+            margin: 1rem 0;
+        }
+        .tab-buttons {
+            display: flex;
+            gap: 0.5rem;
+            border-bottom: 2px solid #ddd;
+            margin-bottom: 1rem;
+        }
+        .tab-btn {
+            background: none;
+            border: none;
+            padding: 0.8rem 1.5rem;
+            font-size: 1em;
+            cursor: pointer;
+            color: #666;
+            border-bottom: 3px solid transparent;
+            transition: all 0.3s;
+        }
+        .tab-btn:hover {
+            color: #667eea;
+        }
+        .tab-btn.active {
+            color: #667eea;
+            border-bottom-color: #667eea;
+        }
+        .tab-content {
+            display: none;
+        }
+        .tab-content.active {
+            display: block;
+        }
+        .try-it-section {
+            display: flex;
+            flex-direction: column;
+            gap: 1rem;
+        }
+        .json-input {
+            width: 100%;
+            min-height: 150px;
+            padding: 1rem;
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.9em;
+            border: 2px solid #ddd;
+            border-radius: 8px;
+            background: #2d2d2d;
+            color: #f8f8f2;
+            resize: vertical;
+        }
+        .json-input:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+        .try-btn {
+            align-self: flex-start;
+            background: #667eea;
+            color: white;
+            border: none;
+            padding: 0.8rem 2rem;
+            border-radius: 8px;
+            font-size: 1em;
+            cursor: pointer;
+            transition: background 0.3s;
+        }
+        .try-btn:hover {
+            background: #5568d3;
+        }
+        .try-btn:disabled {
+            background: #ccc;
+            cursor: not-allowed;
+        }
+        .response-output {
+            background: #2d2d2d;
+            color: #f8f8f2;
+            padding: 1rem;
+            border-radius: 8px;
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.9em;
+            min-height: 100px;
+            white-space: pre-wrap;
+            word-wrap: break-word;
+            display: none;
+        }
+        .response-output.show {
+            display: block;
+        }
+        .response-output.error {
+            background: #ffebee;
+            color: #c62828;
+        }
+        .response-output.success {
+            background: #e8f5e9;
+            color: #2e7d32;
+        }
+        .type-link {
+            color: #667eea;
+            text-decoration: none;
+            border-bottom: 1px dashed #667eea;
+            transition: all 0.2s;
+        }
+        .type-link:hover {
+            color: #5568d3;
+            border-bottom-color: #5568d3;
+        }
+        .type-union {
+            margin-top: 0.5rem;
+        }
+        .type-union-label {
+            font-weight: 600;
+            color: #4a5568;
+            display: block;
+            margin-bottom: 0.25rem;
+        }
+        .type-union ul {
+            margin: 0.25rem 0 0 0;
+            padding-left: 1.5rem;
+            list-style-type: disc;
+        }
+        .type-union li {
+            margin: 0.25rem 0;
+            color: #2d3748;
+        }
+        .param-type-union {
+            display: block;
+            margin-top: 0.25rem;
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>Commands Reference</h1>
+        <p>Complete list of Music Assistant API commands</p>
+    </div>
+
+    <div class="nav-container">
+        <div class="search-box">
+            <input type="text" id="search" placeholder="Search commands..." />
+        </div>
+        <div class="quick-nav">
+"""
+
+    # Add quick navigation links
+    for category in sorted(categories.keys()):
+        category_display = category.replace("_", " ").title()
+        html += f'            <a href="#{category}">{category_display}</a>\n'
+
+    html += """        </div>
+    </div>
+
+    <div class="container">
+"""
+
+    for category, commands in sorted(categories.items()):
+        category_display = category.replace("_", " ").title()
+        html += f'        <div class="category" id="{category}" data-category="{category}">\n'
+        html += f'            <div class="category-header">{category_display}</div>\n'
+        html += '            <div class="category-content">\n'
+
+        for command, handler in commands:
+            # Parse docstring
+            summary, description, param_descriptions = _parse_docstring(handler.target)
+
+            # Get return type
+            return_type = handler.type_hints.get("return", Any)
+            return_type_str = _python_type_to_json_type(str(return_type))
+
+            html += f'                <div class="command" data-command="{command}">\n'
+            html += (
+                '                    <div class="command-header" onclick="toggleCommand(this)">\n'
+            )
+            html += '                        <div class="command-title">\n'
+            html += f'                            <div class="command-name">{command}</div>\n'
+            if summary:
+                summary_escaped = summary.replace("<", "&lt;").replace(">", "&gt;")
+                html += (
+                    f'                            <div class="command-summary">'
+                    f"{summary_escaped}</div>\n"
+                )
+            html += "                        </div>\n"
+            html += '                        <div class="command-expand-icon">▼</div>\n'
+            html += "                    </div>\n"
+
+            # Command details (collapsed by default)
+            html += '                    <div class="command-details">\n'
+
+            if description and description != summary:
+                desc_escaped = description.replace("<", "&lt;").replace(">", "&gt;")
+                html += (
+                    f'                        <div class="command-description">'
+                    f"{desc_escaped}</div>\n"
+                )
+
+            # Return type with links
+            return_type_html = _make_type_links(return_type_str, server_url)
+            html += '                        <div class="return-type">\n'
+            html += '                            <span class="return-type-label">Returns:</span>\n'
+            html += f'                            <span class="return-type-value">{return_type_html}</span>\n'  # noqa: E501
+            html += "                        </div>\n"
+
+            # Parameters
+            params = []
+            for param_name, param in handler.signature.parameters.items():
+                if param_name == "self":
+                    continue
+                is_required = param.default is inspect.Parameter.empty
+                param_type = handler.type_hints.get(param_name, Any)
+                type_str = str(param_type)
+                json_type_str = _python_type_to_json_type(type_str)
+                param_desc = param_descriptions.get(param_name, "")
+                params.append((param_name, is_required, json_type_str, param_desc))
+
+            if params:
+                html += '                    <div class="params-section">\n'
+                html += '                        <div class="params-title">Parameters:</div>\n'
+                for param_name, is_required, type_str, param_desc in params:
+                    # Convert type to HTML with links (use list format for unions)
+                    type_html = _make_type_links(type_str, server_url, as_list=True)
+                    html += '                        <div class="param">\n'
+                    html += (
+                        f'                            <span class="param-name">'
+                        f"{param_name}</span>\n"
+                    )
+                    if is_required:
+                        html += (
+                            '                            <span class="param-required">'
+                            "REQUIRED</span>\n"
+                        )
+                    # If it's a list format, display it differently
+                    if "<ul>" in type_html:
+                        html += (
+                            '                            <div class="param-type-union">'
+                            f"{type_html}</div>\n"
+                        )
+                    else:
+                        html += (
+                            f'                            <span class="param-type">'
+                            f"{type_html}</span>\n"
+                        )
+                    if param_desc:
+                        html += (
+                            f'                            <div class="param-description">'
+                            f"{param_desc}</div>\n"
+                        )
+                    html += "                        </div>\n"
+                html += "                    </div>\n"
+
+            # Build example curl command with JSON types
+            example_args: dict[str, Any] = {}
+            for param_name, is_required, type_str, _ in params:
+                # Include optional params if few params
+                if is_required or len(params) <= 2:
+                    if type_str == "string":
+                        example_args[param_name] = "example_value"
+                    elif type_str == "integer":
+                        example_args[param_name] = 0
+                    elif type_str == "number":
+                        example_args[param_name] = 0.0
+                    elif type_str == "boolean":
+                        example_args[param_name] = True
+                    elif type_str == "object":
+                        example_args[param_name] = {}
+                    elif type_str == "null":
+                        example_args[param_name] = None
+                    elif type_str.startswith("Array of "):
+                        # Array type with item type specified (e.g., "Array of Artist")
+                        item_type = type_str[9:]  # Remove "Array of "
+                        if item_type in {"string", "integer", "number", "boolean"}:
+                            example_args[param_name] = []
+                        else:
+                            # Complex type array
+                            example_args[param_name] = [
+                                {"_comment": f"See {item_type} schema in Swagger UI"}
+                            ]
+                    else:
+                        # Complex type (Artist, Player, etc.) - use placeholder object
+                        # Extract the primary type if it's a union (e.g., "Artist | string")
+                        primary_type = type_str.split(" | ")[0] if " | " in type_str else type_str
+                        example_args[param_name] = {
+                            "_comment": f"See {primary_type} schema in Swagger UI"
+                        }
+
+            request_body: dict[str, Any] = {"command": command}
+            if example_args:
+                request_body["args"] = example_args
+
+            curl_cmd = (
+                f"curl -X POST {server_url}/api \\\n"
+                '  -H "Content-Type: application/json" \\\n'
+                f"  -d '{json.dumps(request_body, indent=2)}'"
+            )
+
+            # Add tabs for curl example and try it
+            html += '                    <div class="tabs">\n'
+            html += '                        <div class="tab-buttons">\n'
+            html += (
+                '                            <button class="tab-btn active" '
+                f"onclick=\"switchTab(this, 'curl-{command.replace('/', '-')}')\">cURL</button>\n"
+            )
+            html += (
+                '                            <button class="tab-btn" '
+                f"onclick=\"switchTab(this, 'tryit-{command.replace('/', '-')}')\">Try It</button>\n"  # noqa: E501
+            )
+            html += "                        </div>\n"
+
+            # cURL tab
+            html += f'                        <div id="curl-{command.replace("/", "-")}" class="tab-content active">\n'  # noqa: E501
+            html += '                            <div class="example">\n'
+            html += (
+                '                                <button class="copy-btn" '
+                'onclick="copyCode(this)">Copy</button>\n'
+            )
+            html += f"                                <pre>{curl_cmd}</pre>\n"
+            html += "                            </div>\n"
+            html += "                        </div>\n"
+
+            # Try It tab
+            html += f'                        <div id="tryit-{command.replace("/", "-")}" class="tab-content">\n'  # noqa: E501
+            html += '                            <div class="try-it-section">\n'
+            # HTML-escape the JSON for the textarea
+            json_str = json.dumps(request_body, indent=2)
+            # Escape HTML entities
+            json_str_escaped = (
+                json_str.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace('"', "&quot;")
+                .replace("'", "&#39;")
+            )
+            html += f'                                <textarea class="json-input">{json_str_escaped}</textarea>\n'  # noqa: E501
+            html += (
+                f'                                <button class="try-btn" '
+                f"onclick=\"tryCommand(this, '{command}')\">Execute</button>\n"
+            )
+            html += '                                <div class="response-output"></div>\n'
+            html += "                            </div>\n"
+            html += "                        </div>\n"
+
+            html += "                    </div>\n"
+            # Close command-details div
+            html += "                    </div>\n"
+            # Close command div
+            html += "                </div>\n"
+
+        html += "            </div>\n"
+        html += "        </div>\n"
+
+    html += """    </div>
+
+    <script>
+        // Search functionality
+        document.getElementById('search').addEventListener('input', function(e) {
+            const searchTerm = e.target.value.toLowerCase();
+            const commands = document.querySelectorAll('.command');
+            const categories = document.querySelectorAll('.category');
+
+            commands.forEach(command => {
+                const commandName = command.dataset.command;
+                const commandText = command.textContent.toLowerCase();
+                if (commandName.includes(searchTerm) || commandText.includes(searchTerm)) {
+                    command.classList.remove('hidden');
+                } else {
+                    command.classList.add('hidden');
+                }
+            });
+
+            // Hide empty categories
+            categories.forEach(category => {
+                const visibleCommands = category.querySelectorAll('.command:not(.hidden)');
+                if (visibleCommands.length === 0) {
+                    category.classList.add('hidden');
+                } else {
+                    category.classList.remove('hidden');
+                }
+            });
+        });
+
+        // Toggle command details
+        function toggleCommand(header) {
+            const command = header.parentElement;
+            const details = command.querySelector('.command-details');
+            const icon = header.querySelector('.command-expand-icon');
+
+            details.classList.toggle('show');
+            icon.classList.toggle('expanded');
+        }
+
+        // Copy to clipboard
+        function copyCode(button) {
+            const code = button.nextElementSibling.textContent;
+            navigator.clipboard.writeText(code).then(() => {
+                const originalText = button.textContent;
+                button.textContent = 'Copied!';
+                setTimeout(() => {
+                    button.textContent = originalText;
+                }, 2000);
+            });
+        }
+
+        // Tab switching
+        function switchTab(button, tabId) {
+            const tabButtons = button.parentElement;
+            const tabs = tabButtons.parentElement;
+
+            // Remove active class from all buttons and tabs
+            tabButtons.querySelectorAll('.tab-btn').forEach(btn => {
+                btn.classList.remove('active');
+            });
+            tabs.querySelectorAll('.tab-content').forEach(content => {
+                content.classList.remove('active');
+            });
+
+            // Add active class to clicked button and corresponding tab
+            button.classList.add('active');
+            document.getElementById(tabId).classList.add('active');
+        }
+
+        // Try command functionality
+        async function tryCommand(button, commandName) {
+            const section = button.parentElement;
+            const textarea = section.querySelector('.json-input');
+            const output = section.querySelector('.response-output');
+
+            // Disable button while processing
+            button.disabled = true;
+            button.textContent = 'Executing...';
+
+            // Clear previous output
+            output.className = 'response-output show';
+            output.textContent = 'Loading...';
+
+            try {
+                // Parse JSON from textarea
+                let requestBody;
+                try {
+                    requestBody = JSON.parse(textarea.value);
+                } catch (e) {
+                    throw new Error('Invalid JSON: ' + e.message);
+                }
+
+                // Make API request
+                const response = await fetch('/api', {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/json',
+                    },
+                    body: JSON.stringify(requestBody)
+                });
+
+                let result;
+                const contentType = response.headers.get('content-type');
+                if (contentType && contentType.includes('application/json')) {
+                    result = await response.json();
+                } else {
+                    const text = await response.text();
+                    result = { error: text };
+                }
+
+                // Display result
+                if (response.ok) {
+                    output.className = 'response-output show success';
+                    output.textContent = 'Success!\\n\\n' + JSON.stringify(result, null, 2);
+                } else {
+                    output.className = 'response-output show error';
+                    // Try to extract a meaningful error message
+                    let errorMsg = 'Request failed';
+                    if (result.error) {
+                        errorMsg = result.error;
+                    } else if (result.message) {
+                        errorMsg = result.message;
+                    } else if (typeof result === 'string') {
+                        errorMsg = result;
+                    } else {
+                        errorMsg = JSON.stringify(result, null, 2);
+                    }
+                    output.textContent = 'Error: ' + errorMsg;
+                }
+            } catch (error) {
+                output.className = 'response-output show error';
+                // Provide more user-friendly error messages
+                if (error.message.includes('Invalid JSON')) {
+                    output.textContent = 'JSON Syntax Error: Please check your request format. '
+                        + error.message;
+                } else if (error.message.includes('Failed to fetch')) {
+                    output.textContent = 'Connection Error: Unable to reach the API server. '
+                        + 'Please check if the server is running.';
+                } else {
+                    output.textContent = 'Error: ' + error.message;
+                }
+            } finally {
+                button.disabled = false;
+                button.textContent = 'Execute';
+            }
+        }
+    </script>
+</body>
+</html>
+"""
+
+    return html
+
+
+def generate_schemas_reference(  # noqa: PLR0915
+    command_handlers: dict[str, APICommandHandler],
+) -> str:
+    """Generate HTML schemas reference page with all data models."""
+    # Collect all unique schemas from commands
+    schemas: dict[str, Any] = {}
+
+    for handler in command_handlers.values():
+        # Collect schemas from parameters
+        for param_name in handler.signature.parameters:
+            if param_name == "self":
+                continue
+            param_type = handler.type_hints.get(param_name, Any)
+            if param_type is not Any and str(param_type) != "typing.Any":
+                _get_type_schema(param_type, schemas)
+
+        # Collect schemas from return type
+        return_type = handler.type_hints.get("return", Any)
+        if return_type is not Any and str(return_type) != "typing.Any":
+            _get_type_schema(return_type, schemas)
+
+    # Build HTML
+    html = """<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Music Assistant API - Schemas Reference</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
+                         Ubuntu, Cantarell, sans-serif;
+            background: #f5f5f5;
+            line-height: 1.6;
+        }
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 1.5rem 2rem;
+            text-align: center;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+        }
+        .header h1 {
+            font-size: 1.8em;
+            margin-bottom: 0.3rem;
+            font-weight: 600;
+        }
+        .header p {
+            font-size: 0.95em;
+            opacity: 0.9;
+        }
+        .nav-container {
+            background: white;
+            padding: 1rem 2rem;
+            box-shadow: 0 2px 5px rgba(0,0,0,0.05);
+            position: sticky;
+            top: 0;
+            z-index: 100;
+        }
+        .search-box input {
+            width: 100%;
+            max-width: 600px;
+            padding: 0.6rem 1rem;
+            font-size: 0.95em;
+            border: 2px solid #ddd;
+            border-radius: 8px;
+            display: block;
+            margin: 0 auto;
+        }
+        .search-box input:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+        .container {
+            max-width: 1200px;
+            margin: 2rem auto;
+            padding: 0 2rem;
+        }
+        .schema {
+            background: white;
+            margin-bottom: 1.5rem;
+            border-radius: 12px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
+            overflow: hidden;
+            scroll-margin-top: 100px;
+        }
+        .schema-header {
+            background: #667eea;
+            color: white;
+            padding: 1rem 1.5rem;
+            cursor: pointer;
+            user-select: none;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+        .schema-header:hover {
+            background: #5568d3;
+        }
+        .schema-name {
+            font-size: 1.3em;
+            font-weight: 600;
+            font-family: 'Monaco', 'Courier New', monospace;
+        }
+        .schema-expand-icon {
+            font-size: 1.2em;
+            transition: transform 0.3s;
+        }
+        .schema-expand-icon.expanded {
+            transform: rotate(180deg);
+        }
+        .schema-content {
+            padding: 1.5rem;
+            display: none;
+        }
+        .schema-content.show {
+            display: block;
+        }
+        .schema-description {
+            color: #666;
+            margin-bottom: 1rem;
+            font-style: italic;
+        }
+        .properties-section {
+            margin-top: 1rem;
+        }
+        .properties-title {
+            font-weight: 600;
+            color: #333;
+            margin-bottom: 0.5rem;
+            font-size: 1.1em;
+        }
+        .property {
+            background: #f8f9fa;
+            padding: 0.75rem 1rem;
+            margin: 0.5rem 0;
+            border-radius: 6px;
+            border-left: 3px solid #667eea;
+        }
+        .property-name {
+            font-family: 'Monaco', 'Courier New', monospace;
+            color: #667eea;
+            font-weight: 600;
+            font-size: 1em;
+        }
+        .property-required {
+            display: inline-block;
+            background: #e74c3c;
+            color: white;
+            padding: 0.15rem 0.5rem;
+            border-radius: 4px;
+            font-size: 0.75em;
+            font-weight: 600;
+            margin-left: 0.5rem;
+        }
+        .property-optional {
+            display: inline-block;
+            background: #95a5a6;
+            color: white;
+            padding: 0.15rem 0.5rem;
+            border-radius: 4px;
+            font-size: 0.75em;
+            font-weight: 600;
+            margin-left: 0.5rem;
+        }
+        .property-nullable {
+            display: inline-block;
+            background: #f39c12;
+            color: white;
+            padding: 0.15rem 0.5rem;
+            border-radius: 4px;
+            font-size: 0.75em;
+            font-weight: 600;
+            margin-left: 0.5rem;
+        }
+        .property-type {
+            color: #888;
+            font-size: 0.9em;
+            margin-left: 0.5rem;
+            font-family: 'Monaco', 'Courier New', monospace;
+        }
+        .property-description {
+            color: #666;
+            margin-top: 0.25rem;
+            font-size: 0.95em;
+        }
+        .type-link {
+            color: #667eea;
+            text-decoration: none;
+            border-bottom: 1px dashed #667eea;
+            transition: all 0.2s;
+        }
+        .type-link:hover {
+            color: #5568d3;
+            border-bottom-color: #5568d3;
+        }
+        .hidden {
+            display: none;
+        }
+        .back-link {
+            display: inline-block;
+            margin-bottom: 1rem;
+            padding: 0.5rem 1rem;
+            background: #667eea;
+            color: white;
+            text-decoration: none;
+            border-radius: 6px;
+            transition: background 0.2s;
+        }
+        .back-link:hover {
+            background: #5568d3;
+        }
+        .openapi-link {
+            display: inline-block;
+            padding: 0.5rem 1rem;
+            background: #2e7d32;
+            color: white;
+            text-decoration: none;
+            border-radius: 6px;
+            transition: background 0.2s;
+        }
+        .openapi-link:hover {
+            background: #1b5e20;
+        }
+        .enum-values {
+            margin-top: 0.5rem;
+            padding: 0.5rem;
+            background: #fff;
+            border-radius: 4px;
+        }
+        .enum-values-title {
+            font-weight: 600;
+            color: #555;
+            font-size: 0.9em;
+            margin-bottom: 0.25rem;
+        }
+        .enum-value {
+            display: inline-block;
+            padding: 0.2rem 0.5rem;
+            margin: 0.2rem;
+            background: #e8f5e9;
+            border-radius: 4px;
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.85em;
+            color: #2e7d32;
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>Schemas Reference</h1>
+        <p>Data models and types used in the Music Assistant API</p>
+    </div>
+
+    <div class="nav-container">
+        <div class="search-box">
+            <input type="text" id="search" placeholder="Search schemas..." />
+        </div>
+    </div>
+
+    <div class="container">
+        <a href="/api-docs" class="back-link">← Back to API Documentation</a>
+"""
+
+    # Add each schema
+    for schema_name in sorted(schemas.keys()):
+        schema_def = schemas[schema_name]
+        html += (
+            f'        <div class="schema" id="schema-{schema_name}" data-schema="{schema_name}">\n'
+        )
+        html += '            <div class="schema-header" onclick="toggleSchema(this)">\n'
+        html += f'                <div class="schema-name">{schema_name}</div>\n'
+        html += '                <div class="schema-expand-icon">▼</div>\n'
+        html += "            </div>\n"
+        html += '            <div class="schema-content">\n'
+
+        # Add description if available
+        if "description" in schema_def:
+            desc = schema_def["description"]
+            html += f'                <div class="schema-description">{desc}</div>\n'
+
+        # Add properties if available
+        if "properties" in schema_def:
+            html += '                <div class="properties-section">\n'
+            html += '                    <div class="properties-title">Properties:</div>\n'
+
+            # Get required fields list
+            required_fields = schema_def.get("required", [])
+
+            for prop_name, prop_def in schema_def["properties"].items():
+                html += '                    <div class="property">\n'
+                html += f'                        <span class="property-name">{prop_name}</span>\n'
+
+                # Check if field is required
+                is_required = prop_name in required_fields
+
+                # Check if field is nullable (type is "null" or has null in anyOf/oneOf)
+                is_nullable = False
+                if "type" in prop_def and prop_def["type"] == "null":
+                    is_nullable = True
+                elif "anyOf" in prop_def:
+                    is_nullable = any(item.get("type") == "null" for item in prop_def["anyOf"])
+                elif "oneOf" in prop_def:
+                    is_nullable = any(item.get("type") == "null" for item in prop_def["oneOf"])
+
+                # Add required/optional badge
+                if is_required:
+                    html += (
+                        '                        <span class="property-required">REQUIRED</span>\n'
+                    )
+                else:
+                    html += (
+                        '                        <span class="property-optional">OPTIONAL</span>\n'
+                    )
+
+                # Add nullable badge if applicable
+                if is_nullable:
+                    html += (
+                        '                        <span class="property-nullable">NULLABLE</span>\n'
+                    )
+
+                # Add type
+                if "type" in prop_def:
+                    prop_type = prop_def["type"]
+                    html += (
+                        f'                        <span class="property-type">{prop_type}</span>\n'
+                    )
+                elif "$ref" in prop_def:
+                    # Extract type name from $ref
+                    ref_type = prop_def["$ref"].split("/")[-1]
+                    html += (
+                        f'                        <span class="property-type">'
+                        f'<a href="#schema-{ref_type}" class="type-link">'
+                        f"{ref_type}</a></span>\n"
+                    )
+
+                # Add description
+                if "description" in prop_def:
+                    prop_desc = prop_def["description"]
+                    html += (
+                        f'                        <div class="property-description">'
+                        f"{prop_desc}</div>\n"
+                    )
+
+                # Add enum values if present
+                if "enum" in prop_def:
+                    html += '                        <div class="enum-values">\n'
+                    html += (
+                        '                            <div class="enum-values-title">'
+                        "Possible values:</div>\n"
+                    )
+                    for enum_val in prop_def["enum"]:
+                        html += (
+                            f'                            <span class="enum-value">'
+                            f"{enum_val}</span>\n"
+                        )
+                    html += "                        </div>\n"
+
+                html += "                    </div>\n"
+
+            html += "                </div>\n"
+
+        html += "            </div>\n"
+        html += "        </div>\n"
+
+    html += """
+        <div style="text-align: center; margin-top: 3rem; padding: 2rem 0;">
+            <a href="/api-docs/openapi.json" class="openapi-link" download>
+                📄 Download OpenAPI Spec
+            </a>
+        </div>
+    </div>
+
+    <script>
+        // Search functionality
+        document.getElementById('search').addEventListener('input', function(e) {
+            const searchTerm = e.target.value.toLowerCase();
+            const schemas = document.querySelectorAll('.schema');
+
+            schemas.forEach(schema => {
+                const schemaName = schema.dataset.schema;
+                const schemaText = schema.textContent.toLowerCase();
+                const nameMatch = schemaName.toLowerCase().includes(searchTerm);
+                const textMatch = schemaText.includes(searchTerm);
+                if (nameMatch || textMatch) {
+                    schema.classList.remove('hidden');
+                } else {
+                    schema.classList.add('hidden');
+                }
+            });
+        });
+
+        // Toggle schema details
+        function toggleSchema(header) {
+            const schema = header.parentElement;
+            const content = schema.querySelector('.schema-content');
+            const icon = header.querySelector('.schema-expand-icon');
+
+            content.classList.toggle('show');
+            icon.classList.toggle('expanded');
+        }
+
+        // Handle deep linking - expand and scroll to schema on page load
+        window.addEventListener('DOMContentLoaded', function() {
+            const hash = window.location.hash;
+            if (hash && hash.startsWith('#schema-')) {
+                const schemaElement = document.querySelector(hash);
+                if (schemaElement) {
+                    // Expand the schema
+                    const content = schemaElement.querySelector('.schema-content');
+                    const icon = schemaElement.querySelector('.schema-expand-icon');
+                    if (content && icon) {
+                        content.classList.add('show');
+                        icon.classList.add('expanded');
+                    }
+                    // Scroll to it
+                    setTimeout(() => {
+                        schemaElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+                        // Highlight temporarily
+                        schemaElement.style.transition = 'background-color 0.3s';
+                        schemaElement.style.backgroundColor = '#fff3cd';
+                        setTimeout(() => {
+                            schemaElement.style.backgroundColor = '';
+                        }, 2000);
+                    }, 100);
+                }
+            }
+        });
+
+        // Listen for hash changes (when user clicks a type link)
+        window.addEventListener('hashchange', function() {
+            const hash = window.location.hash;
+            if (hash && hash.startsWith('#schema-')) {
+                const schemaElement = document.querySelector(hash);
+                if (schemaElement) {
+                    // Expand if collapsed
+                    const content = schemaElement.querySelector('.schema-content');
+                    const icon = schemaElement.querySelector('.schema-expand-icon');
+                    if (content && !content.classList.contains('show')) {
+                        content.classList.add('show');
+                        icon.classList.add('expanded');
+                    }
+                    // Scroll to it
+                    schemaElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+                    // Highlight temporarily
+                    schemaElement.style.transition = 'background-color 0.3s';
+                    schemaElement.style.backgroundColor = '#fff3cd';
+                    setTimeout(() => {
+                        schemaElement.style.backgroundColor = '';
+                    }, 2000);
+                }
+            }
+        });
+    </script>
+</body>
+</html>
+"""
+
+    return html
+
+
 def generate_html_docs(  # noqa: PLR0915
     command_handlers: dict[str, APICommandHandler],
     server_url: str = "http://localhost:8095",
index ee3ee26c7b5c39f11c3cea7fb968c20c9145cd96..04bfc12ee847664c01624bbd0e23f023c24f0fb1 100644 (file)
         .header {
             background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
             color: white;
-            padding: 60px 40px;
+            padding: 40px 40px;
             text-align: center;
             display: flex;
             flex-direction: column;
             align-items: center;
-            gap: 20px;
+            gap: 15px;
         }
         .header .logo {
-            width: 80px;
-            height: 80px;
+            width: 60px;
+            height: 60px;
         }
         .header h1 {
-            font-size: 3em;
+            font-size: 2.2em;
             margin: 0;
-            font-weight: 700;
+            font-weight: 600;
         }
         .header .version {
-            font-size: 1.2em;
+            font-size: 1em;
             opacity: 0.9;
             font-weight: 300;
         }
         .content {
-            padding: 50px 40px;
+            padding: 35px 40px;
         }
         .section {
-            margin-bottom: 50px;
+            margin-bottom: 35px;
         }
         .section h2 {
             color: #667eea;
-            font-size: 2em;
-            margin-bottom: 20px;
-            padding-bottom: 10px;
-            border-bottom: 3px solid #667eea;
+            font-size: 1.6em;
+            margin-bottom: 15px;
+            padding-bottom: 8px;
+            border-bottom: 2px solid #667eea;
         }
         .section h3 {
             color: #764ba2;
-            font-size: 1.5em;
-            margin: 30px 0 15px 0;
+            font-size: 1.3em;
+            margin: 25px 0 12px 0;
         }
         .section p {
-            margin-bottom: 15px;
-            font-size: 1.05em;
-            line-height: 1.8;
+            margin-bottom: 12px;
+            font-size: 1em;
+            line-height: 1.6;
         }
         .api-boxes {
             display: grid;
             grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
-            gap: 25px;
-            margin: 30px 0;
+            gap: 20px;
+            margin: 20px 0;
         }
         .api-box {
             background: #f8f9fa;
-            padding: 30px;
+            padding: 25px;
             border-radius: 12px;
             border-left: 5px solid #667eea;
             transition: transform 0.2s, box-shadow 0.2s;
@@ -93,8 +93,8 @@
         }
         .api-box h4 {
             color: #667eea;
-            font-size: 1.3em;
-            margin-bottom: 12px;
+            font-size: 1.2em;
+            margin-bottom: 10px;
         }
         .api-box p {
             color: #666;
             </div>
 
             <div class="section">
-                <h2>API Explorers</h2>
+                <h2>API Documentation</h2>
                 <p>
-                    Interactive documentation where you can test all API endpoints directly in your browser:
+                    Explore the Music Assistant API with our documentation tools:
                 </p>
                 <div class="api-boxes">
                     <div class="api-box">
-                        <h4>ð\9f\93\98 Swagger UI</h4>
-                        <p>Test endpoints interactively, see request/response examples, and try out the API</p>
-                        <a href="{BASE_URL}/api-docs/swagger" class="btn">Open Swagger UI</a>
+                        <h4>ð\9f\93\9a Commands Reference</h4>
+                        <p>Complete list of all available commands with parameters, descriptions, working curl examples, and interactive testing</p>
+                        <a href="{BASE_URL}/api-docs/commands" class="btn">View Commands</a>
                     </div>
                     <div class="api-box">
-                        <h4>ð\9f\93\97 ReDoc</h4>
-                        <p>Beautiful, detailed documentation with a clean three-panel layout</p>
-                        <a href="{BASE_URL}/api-docs/redoc" class="btn">Open ReDoc</a>
+                        <h4>ð\9f\93\8a Schemas Reference</h4>
+                        <p>All data models and types with their properties, descriptions, and relationships</p>
+                        <a href="{BASE_URL}/api-docs/schemas" class="btn">View Schemas</a>
                     </div>
                     <div class="api-box">
-                        <h4>ð\9f\93\84 OpenAPI Spec</h4>
-                        <p>Download the OpenAPI 3.0 specification for automated client generation</p>
-                        <a href="{BASE_URL}/api-docs/openapi.json" class="btn">Download JSON</a>
+                        <h4>ð\9f\93\98 Swagger UI</h4>
+                        <p>Interactive API explorer with OpenAPI specification and "Try it out" functionality</p>
+                        <a href="{BASE_URL}/api-docs/swagger" class="btn">Open Swagger UI</a>
                     </div>
                 </div>
             </div>
                     The WebSocket API provides <span class="highlight">real-time bidirectional communication</span>
                     and automatic event notifications. Perfect for applications that need live updates.
                 </p>
+                <div class="info-box">
+                    <strong>Note:</strong> WebSocket messages require a <code>message_id</code> field to match requests with responses.
+                    This allows multiple concurrent requests over the same connection.
+                </div>
                 <div class="code-block">
 <span class="comment"># Connect to WebSocket</span>
 ws://{SERVER_HOST}/ws
 
-<span class="comment"># Send a command</span>
+<span class="comment"># Send a command (message_id is REQUIRED)</span>
 {
   <span class="string">"message_id"</span>: <span class="string">"unique-id-123"</span>,
   <span class="string">"command"</span>: <span class="string">"players/all"</span>,
@@ -302,10 +306,16 @@ ws://{SERVER_HOST}/ws
 }
                 </div>
 
-                <h3>REST API (Simple)</h3>
+                <h3>HTTP API (RPC)</h3>
                 <p>
-                    For simple, one-off commands without needing real-time updates.
+                    The HTTP API provides a simple RPC-like interface for executing commands.
+                    This allows you to call the same commands available via WebSocket over a simple HTTP POST endpoint.
+                    Perfect for one-off commands without needing real-time updates.
                 </p>
+                <div class="info-box">
+                    <strong>Note:</strong> The <code>message_id</code> field is <strong>optional</strong> for HTTP requests
+                    since each HTTP request is isolated. The response returns the command result directly.
+                </div>
                 <div class="code-block">
 <span class="comment"># Get all players</span>
 curl -X POST {BASE_URL}/api \
diff --git a/music_assistant/helpers/resources/redoc_ui.html b/music_assistant/helpers/resources/redoc_ui.html
deleted file mode 100644 (file)
index ed67b29..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Music Assistant API Documentation</title>
-    <style>
-        body {
-            margin: 0;
-            padding: 0;
-        }
-    </style>
-</head>
-<body>
-    <redoc spec-url="/api-docs/openapi.json"
-           hide-hostname="true"
-           theme='{"colors": {"primary": {"main": "#667eea"}}, "typography": {"fontSize": "15px"}}'
-           hide-download-button="false"
-           expand-responses="200,201"
-           json-sample-expand-level="2"
-           scroll-y-offset="0"
-           path-in-middle-panel="true"
-           native-scrollbars="false"
-           search-box-enabled="true">
-    </redoc>
-    <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
-</body>
-</html>
index 3eed9dcf0d550cb8f14c4061f3d6394f4273698e..872a6a70c4f50c8547e6cc893e211cdf257c567f 100644 (file)
@@ -3,7 +3,7 @@
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Music Assistant API Explorer</title>
+    <title>Music Assistant API - Swagger UI</title>
     <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css">
     <style>
         body {
@@ -27,7 +27,6 @@
     <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"></script>
     <script>
         window.onload = function() {
-            // Get the current origin to build the OpenAPI spec URL
             const openapiUrl = window.location.origin + '/api-docs/openapi.json';
 
             const ui = SwaggerUIBundle({
@@ -42,7 +41,7 @@
                     SwaggerUIBundle.plugins.DownloadUrl
                 ],
                 layout: "StandaloneLayout",
-                defaultModelsExpandDepth: 1,
+                defaultModelsExpandDepth: 3,
                 defaultModelExpandDepth: 3,
                 docExpansion: "list",
                 filter: true,
             });
 
             window.ui = ui;
+
+            // Handle deep linking to schemas after UI is loaded
+            // Swagger UI doesn't natively support schema deep linking, so we implement it manually
+            function scrollToSchema() {
+                const hash = window.location.hash;
+
+                // Check if we have a schema link (format: #/components/schemas/SchemaName or #model-SchemaName)
+                let schemaName = null;
+                if (hash.includes('/schemas/')) {
+                    schemaName = hash.split('/schemas/')[1];
+                } else if (hash.startsWith('#model-')) {
+                    schemaName = hash.substring(7); // Remove '#model-' prefix
+                }
+
+                if (!schemaName) {
+                    return;
+                }
+
+                // Wait for models section to be rendered
+                const modelsSection = document.querySelector('.models, section.models, [class*="models"]');
+                if (!modelsSection) {
+                    // Models section not rendered yet, try again
+                    setTimeout(scrollToSchema, 200);
+                    return;
+                }
+
+                // Look for the specific model by ID
+                const modelId = `model-${schemaName}`;
+                let modelElement = document.getElementById(modelId);
+
+                if (!modelElement) {
+                    // Try alternative selectors
+                    const allHeadings = document.querySelectorAll('.model-title, [class*="model"] .title');
+                    for (const heading of allHeadings) {
+                        if (heading.textContent.trim() === schemaName) {
+                            modelElement = heading.closest('[id*="model"]') || heading.parentElement;
+                            break;
+                        }
+                    }
+                }
+
+                if (modelElement) {
+                    // Scroll to the model
+                    modelElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+                    // Expand if collapsed
+                    const toggleButton = modelElement.querySelector('button[aria-expanded="false"]');
+                    if (toggleButton) {
+                        toggleButton.click();
+                    }
+
+                    // Highlight temporarily
+                    modelElement.style.transition = 'background-color 0.3s';
+                    modelElement.style.backgroundColor = '#fff3cd';
+                    setTimeout(() => {
+                        modelElement.style.backgroundColor = '';
+                    }, 2000);
+                }
+            }
+
+            // Use MutationObserver to detect when Swagger UI finishes rendering
+            const observer = new MutationObserver((mutations, obs) => {
+                const modelsSection = document.querySelector('.models, section.models, [class*="models"]');
+                if (modelsSection && window.location.hash) {
+                    scrollToSchema();
+                    obs.disconnect(); // Stop observing once we've found and scrolled
+                }
+            });
+
+            // Start observing
+            observer.observe(document.body, {
+                childList: true,
+                subtree: true
+            });
+
+            // Also try after a delay as fallback
+            setTimeout(() => {
+                if (window.location.hash) {
+                    scrollToSchema();
+                }
+                observer.disconnect();
+            }, 3000);
+
+            // Listen for hash changes (when user clicks a schema link)
+            window.addEventListener('hashchange', () => {
+                if (window.location.hash.includes('schema') || window.location.hash.includes('model')) {
+                    setTimeout(scrollToSchema, 100);
+                }
+            });
         }
     </script>
 </body>
index d0cc178e7d227c11a4e5abef3649a3b74c9b51e3..c287eec7a4176d63f175f257181ab207dd4eec13 100644 (file)
@@ -91,7 +91,8 @@ line-length = 100
 target-version = "py312"
 
 [tool.ruff.lint.pydocstyle]
-# Use Google-style docstrings.
+# Use Sphinx-style docstrings with :param: syntax
+# pep257 is the base convention, we enforce Sphinx-style parameters separately
 convention = "pep257"
 
 [tool.ruff.lint.pylint]