From: Marcel van der Veldt
Date: Mon, 3 Nov 2025 15:10:36 +0000 (+0100)
Subject: API docs tweaks
X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=6c1666659c52b787f448c3ca5f33d746cb4e7414;p=music-assistant-server.git
API docs tweaks
---
diff --git a/CLAUDE.md b/CLAUDE.md
index a62f2962..16d846a4 100644
--- 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
diff --git a/music_assistant/controllers/webserver.py b/music_assistant/controllers/webserver.py
index 30d8ca9e..1b2570c6 100644
--- a/music_assistant/controllers/webserver.py
+++ b/music_assistant/controllers/webserver.py
@@ -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."""
diff --git a/music_assistant/helpers/api_docs.py b/music_assistant/helpers/api_docs.py
index 0a6a9d9b..1e561b1a 100644
--- a/music_assistant/helpers/api_docs.py
+++ b/music_assistant/helpers/api_docs.py
@@ -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("", "")
+
+ # 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'{type_name}'
+ 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 = '
Any of:
'
+ for part in parts:
+ linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part)
+ html += f"
{linked_part}
"
+ html += "
"
+ 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 = """
+
+
+
+
+ Music Assistant API - Commands Reference
+
+
+
+
+
Commands Reference
+
Complete list of Music Assistant API commands
+
+
+
+
+
+
+
+"""
+
+ # Add quick navigation links
+ for category in sorted(categories.keys()):
+ category_display = category.replace("_", " ").title()
+ html += f' {category_display}\n'
+
+ html += """
+
+
+
+"""
+
+ for category, commands in sorted(categories.items()):
+ category_display = category.replace("_", " ").title()
+ html += f'
\n'
+ html += f'
{category_display}
\n'
+ html += '
\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'
\n'
+ html += (
+ '
\n'
+ )
+ html += '
\n'
+ html += f'
{command}
\n'
+ if summary:
+ summary_escaped = summary.replace("<", "<").replace(">", ">")
+ html += (
+ f'
'
+ f"{summary_escaped}
\n"
+ )
+ html += "
\n"
+ html += '
â¼
\n'
+ html += "
\n"
+
+ # Command details (collapsed by default)
+ html += '
\n'
+
+ if description and description != summary:
+ desc_escaped = description.replace("<", "<").replace(">", ">")
+ html += (
+ f'
'
+ f"{desc_escaped}
\n"
+ )
+
+ # Return type with links
+ return_type_html = _make_type_links(return_type_str, server_url)
+ html += '
\n'
+ html += ' Returns:\n'
+ html += f' {return_type_html}\n' # noqa: E501
+ html += "
\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 += '
\n'
+ html += '
Parameters:
\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 += '
\n'
+ html += (
+ f' '
+ f"{param_name}\n"
+ )
+ if is_required:
+ html += (
+ ' '
+ "REQUIRED\n"
+ )
+ # If it's a list format, display it differently
+ if "
" in type_html:
+ html += (
+ '
'
+ f"{type_html}
\n"
+ )
+ else:
+ html += (
+ f' '
+ f"{type_html}\n"
+ )
+ if param_desc:
+ html += (
+ f'
'
+ f"{param_desc}
\n"
+ )
+ html += "
\n"
+ html += "
\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 += '
\n'
+ html += '
\n'
+ html += (
+ ' \n"
+ )
+ html += (
+ ' \n" # noqa: E501
+ )
+ html += "
\n"
+
+ # cURL tab
+ html += f'
\n' # noqa: E501
+ html += '
\n'
+ html += (
+ ' \n'
+ )
+ html += f"
{curl_cmd}
\n"
+ html += "
\n"
+ html += "
\n"
+
+ # Try It tab
+ html += f'
\n' # noqa: E501
+ html += '
\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("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'")
+ )
+ html += f' \n' # noqa: E501
+ html += (
+ f' \n"
+ )
+ html += ' \n'
+ html += "
\n"
+ html += "
\n"
+
+ html += "
\n"
+ # Close command-details div
+ html += "
\n"
+ # Close command div
+ html += "
\n"
+
+ html += "
\n"
+ html += "
\n"
+
+ 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 = """
+
+
+
+
+ Music Assistant API - Schemas Reference
+
+
+
+
+
Schemas Reference
+
Data models and types used in the Music Assistant API
+
+
+
+
+
+
+
+
+
+ â Back to API Documentation
+"""
+
+ # Add each schema
+ for schema_name in sorted(schemas.keys()):
+ schema_def = schemas[schema_name]
+ html += (
+ f'
\n'
+ )
+ html += '
\n'
+ html += f'
{schema_name}
\n'
+ html += '
â¼
\n'
+ html += "
\n"
+ html += '
\n'
+
+ # Add description if available
+ if "description" in schema_def:
+ desc = schema_def["description"]
+ html += f'
{desc}
\n'
+
+ # Add properties if available
+ if "properties" in schema_def:
+ html += '
\n'
+ html += '
Properties:
\n'
+
+ # Get required fields list
+ required_fields = schema_def.get("required", [])
+
+ for prop_name, prop_def in schema_def["properties"].items():
+ html += '
\n'
+ html += f' {prop_name}\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 += (
+ ' REQUIRED\n'
+ )
+ else:
+ html += (
+ ' OPTIONAL\n'
+ )
+
+ # Add nullable badge if applicable
+ if is_nullable:
+ html += (
+ ' NULLABLE\n'
+ )
+
+ # Add type
+ if "type" in prop_def:
+ prop_type = prop_def["type"]
+ html += (
+ f' {prop_type}\n'
+ )
+ elif "$ref" in prop_def:
+ # Extract type name from $ref
+ ref_type = prop_def["$ref"].split("/")[-1]
+ html += (
+ f' '
+ f''
+ f"{ref_type}\n"
+ )
+
+ # Add description
+ if "description" in prop_def:
+ prop_desc = prop_def["description"]
+ html += (
+ f'
'
+ f"{prop_desc}
\n"
+ )
+
+ # Add enum values if present
+ if "enum" in prop_def:
+ html += '
\n'
+ html += (
+ '
'
+ "Possible values:
\n"
+ )
+ for enum_val in prop_def["enum"]:
+ html += (
+ f' '
+ f"{enum_val}\n"
+ )
+ html += "
- Interactive documentation where you can test all API endpoints directly in your browser:
+ Explore the Music Assistant API with our documentation tools:
-
ð Swagger UI
-
Test endpoints interactively, see request/response examples, and try out the API
@@ -278,11 +278,15 @@
The WebSocket API provides real-time bidirectional communication
and automatic event notifications. Perfect for applications that need live updates.
+
+ Note: WebSocket messages require a message_id field to match requests with responses.
+ This allows multiple concurrent requests over the same connection.
+
# Connect to WebSocket
ws://{SERVER_HOST}/ws
-# Send a command
+# Send a command (message_id is REQUIRED)
{
"message_id": "unique-id-123",
"command": "players/all",
@@ -302,10 +306,16 @@ ws://{SERVER_HOST}/ws
}
-
REST API (Simple)
+
HTTP API (RPC)
- 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.
+
+ Note: The message_id field is optional for HTTP requests
+ since each HTTP request is isolated. The response returns the command result directly.
+
# Get all players
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
index ed67b298..00000000
--- a/music_assistant/helpers/resources/redoc_ui.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
- Music Assistant API Documentation
-
-
-
-
-
-
-
-
diff --git a/music_assistant/helpers/resources/swagger_ui.html b/music_assistant/helpers/resources/swagger_ui.html
index 3eed9dcf..872a6a70 100644
--- a/music_assistant/helpers/resources/swagger_ui.html
+++ b/music_assistant/helpers/resources/swagger_ui.html
@@ -3,7 +3,7 @@
- Music Assistant API Explorer
+ Music Assistant API - Swagger UI