Add automatically generated API documentation (#2559)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 27 Oct 2025 20:07:50 +0000 (21:07 +0100)
committerGitHub <noreply@github.com>
Mon, 27 Oct 2025 20:07:50 +0000 (21:07 +0100)
music_assistant/controllers/webserver.py
music_assistant/helpers/api_docs.py [new file with mode: 0644]
music_assistant/helpers/resources/api_docs.html [new file with mode: 0644]
music_assistant/helpers/resources/redoc_ui.html [new file with mode: 0644]
music_assistant/helpers/resources/swagger_ui.html [new file with mode: 0644]

index 04e2f7c81bbd1c271ebb52ce85b28a43e4de9153..ffa91ef97019561f87b8fba37083861b47b218f6 100644 (file)
@@ -8,6 +8,7 @@ this webserver allows for more fine grained configuration to better secure it.
 from __future__ import annotations
 
 import asyncio
+import html
 import logging
 import os
 import urllib.parse
@@ -16,6 +17,7 @@ from contextlib import suppress
 from functools import partial
 from typing import TYPE_CHECKING, Any, Final
 
+import aiofiles
 from aiohttp import WSMsgType, web
 from music_assistant_frontend import where as locate_frontend
 from music_assistant_models.api import (
@@ -30,6 +32,7 @@ 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.audio import get_preview_stream
 from music_assistant.helpers.json import json_dumps
 from music_assistant.helpers.util import get_ip_addresses
@@ -151,6 +154,11 @@ class WebserverController(CoreController):
         routes.append(("GET", "/preview", self.serve_preview_stream))
         # add jsonrpc api
         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/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))
         # start the webserver
         all_ip_addresses = await get_ip_addresses()
         default_publish_ip = all_ip_addresses[0]
@@ -260,6 +268,43 @@ class WebserverController(CoreController):
         log_data = await self.mass.get_application_log()
         return web.Response(text=log_data, content_type="text/text")
 
+    async def _handle_api_intro(self, request: web.Request) -> web.Response:
+        """Handle request for API introduction/documentation page."""
+        intro_html_path = os.path.join(
+            os.path.dirname(__file__), "..", "helpers", "resources", "api_docs.html"
+        )
+        # Read the template
+        async with aiofiles.open(intro_html_path) as f:
+            html_content = await f.read()
+
+        # Replace placeholders (escape values to prevent XSS)
+        html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
+        html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
+        html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
+
+        return web.Response(text=html_content, content_type="text/html")
+
+    async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
+        """Handle request for OpenAPI specification (generated on-the-fly)."""
+        spec = generate_openapi_spec(
+            self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
+        )
+        return web.json_response(spec)
+
+    async def _handle_swagger_ui(self, request: web.Request) -> web.Response:
+        """Handle request for Swagger UI."""
+        swagger_html_path = os.path.join(
+            os.path.dirname(__file__), "..", "helpers", "resources", "swagger_ui.html"
+        )
+        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
new file mode 100644 (file)
index 0000000..0a6a9d9
--- /dev/null
@@ -0,0 +1,1027 @@
+"""Helpers for generating API documentation and OpenAPI specifications."""
+
+from __future__ import annotations
+
+import collections.abc
+import inspect
+from collections.abc import Callable
+from dataclasses import MISSING
+from datetime import datetime
+from enum import Enum
+from types import NoneType, UnionType
+from typing import Any, Union, get_args, get_origin, get_type_hints
+
+from music_assistant_models.player import Player as PlayerState
+
+from music_assistant.helpers.api import APICommandHandler
+
+
+def _format_type_name(type_hint: Any) -> str:
+    """Format a type hint as a user-friendly string, using JSON types instead of Python types."""
+    if type_hint is NoneType or type_hint is type(None):
+        return "null"
+
+    # Handle internal Player model - replace with PlayerState
+    if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
+        if (
+            hasattr(type_hint, "__module__")
+            and type_hint.__module__ == "music_assistant.models.player"
+        ):
+            return "PlayerState"
+
+    # Map Python types to JSON types
+    type_name_mapping = {
+        "str": "string",
+        "int": "integer",
+        "float": "number",
+        "bool": "boolean",
+        "dict": "object",
+        "list": "array",
+        "tuple": "array",
+        "set": "array",
+        "frozenset": "array",
+        "Sequence": "array",
+        "UniqueList": "array",
+        "None": "null",
+    }
+
+    if hasattr(type_hint, "__name__"):
+        type_name = str(type_hint.__name__)
+        return type_name_mapping.get(type_name, type_name)
+
+    type_str = str(type_hint).replace("NoneType", "null")
+    # Replace Python types with JSON types in complex type strings
+    for python_type, json_type in type_name_mapping.items():
+        type_str = type_str.replace(python_type, json_type)
+    return type_str
+
+
+def _get_type_schema(  # noqa: PLR0911, PLR0915
+    type_hint: Any, definitions: dict[str, Any]
+) -> dict[str, Any]:
+    """Convert a Python type hint to an OpenAPI schema."""
+    # Handle string type hints from __future__ annotations
+    if isinstance(type_hint, str):
+        # Handle simple primitive type names
+        if type_hint in ("str", "string"):
+            return {"type": "string"}
+        if type_hint in ("int", "integer"):
+            return {"type": "integer"}
+        if type_hint in ("float", "number"):
+            return {"type": "number"}
+        if type_hint in ("bool", "boolean"):
+            return {"type": "boolean"}
+
+        # 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():
+            # 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}"}
+
+        # For complex type expressions like "str | None", "list[str]", return generic object
+        return {"type": "object"}
+
+    # Handle None type
+    if type_hint is NoneType or type_hint is type(None):
+        return {"type": "null"}
+
+    # Handle internal Player model - replace with external PlayerState
+    if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
+        # Check if this is the internal Player (from music_assistant.models.player)
+        if (
+            hasattr(type_hint, "__module__")
+            and type_hint.__module__ == "music_assistant.models.player"
+        ):
+            # Replace with PlayerState from music_assistant_models
+            return _get_type_schema(PlayerState, definitions)
+
+    # Handle Union types (including Optional)
+    origin = get_origin(type_hint)
+    if origin is Union or origin is UnionType:
+        args = get_args(type_hint)
+        # Check if it's Optional (Union with None)
+        non_none_args = [arg for arg in args if arg not in (NoneType, type(None))]
+        if (len(non_none_args) == 1 and NoneType in args) or type(None) in args:
+            # It's Optional[T], make it nullable
+            schema = _get_type_schema(non_none_args[0], definitions)
+            schema["nullable"] = True
+            return schema
+        # It's a union of multiple types
+        return {"oneOf": [_get_type_schema(arg, definitions) for arg in args]}
+
+    # Handle UniqueList (treat as array)
+    if hasattr(type_hint, "__name__") and type_hint.__name__ == "UniqueList":
+        args = get_args(type_hint)
+        if args:
+            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+        return {"type": "array", "items": {}}
+
+    # Handle Sequence types (from collections.abc or typing)
+    if origin is collections.abc.Sequence or (
+        hasattr(origin, "__name__") and origin.__name__ == "Sequence"
+    ):
+        args = get_args(type_hint)
+        if args:
+            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+        return {"type": "array", "items": {}}
+
+    # Handle set/frozenset types
+    if origin in (set, frozenset):
+        args = get_args(type_hint)
+        if args:
+            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+        return {"type": "array", "items": {}}
+
+    # Handle list/tuple types
+    if origin in (list, tuple):
+        args = get_args(type_hint)
+        if args:
+            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+        return {"type": "array", "items": {}}
+
+    # Handle dict types
+    if origin is dict:
+        args = get_args(type_hint)
+        if len(args) == 2:
+            return {
+                "type": "object",
+                "additionalProperties": _get_type_schema(args[1], definitions),
+            }
+        return {"type": "object", "additionalProperties": True}
+
+    # Handle Enum types - add them to definitions as explorable objects
+    if inspect.isclass(type_hint) and issubclass(type_hint, Enum):
+        enum_name = type_hint.__name__
+        if enum_name not in definitions:
+            enum_values = [item.value for item in type_hint]
+            enum_type = type(enum_values[0]).__name__ if enum_values else "string"
+            openapi_type = {
+                "str": "string",
+                "int": "integer",
+                "float": "number",
+                "bool": "boolean",
+            }.get(enum_type, "string")
+
+            # Create a detailed enum definition with descriptions
+            enum_values_str = ", ".join(str(v) for v in enum_values)
+            definitions[enum_name] = {
+                "type": openapi_type,
+                "enum": enum_values,
+                "description": f"Enum: {enum_name}. Possible values: {enum_values_str}",
+            }
+        return {"$ref": f"#/components/schemas/{enum_name}"}
+
+    # Handle datetime
+    if type_hint is datetime:
+        return {"type": "string", "format": "date-time"}
+
+    # Handle primitive types - check both exact type and type name
+    if type_hint is str or (hasattr(type_hint, "__name__") and type_hint.__name__ == "str"):
+        return {"type": "string"}
+    if type_hint is int or (hasattr(type_hint, "__name__") and type_hint.__name__ == "int"):
+        return {"type": "integer"}
+    if type_hint is float or (hasattr(type_hint, "__name__") and type_hint.__name__ == "float"):
+        return {"type": "number"}
+    if type_hint is bool or (hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"):
+        return {"type": "boolean"}
+
+    # Handle complex types (dataclasses, models)
+    # Check for __annotations__ or if it's a class (not already handled above)
+    if hasattr(type_hint, "__annotations__") or (
+        inspect.isclass(type_hint) and not issubclass(type_hint, (str, int, float, bool, Enum))
+    ):
+        type_name = getattr(type_hint, "__name__", str(type_hint))
+        # Add to definitions if not already there
+        if type_name not in definitions:
+            properties = {}
+            required = []
+
+            # Check if this is a dataclass with fields
+            if hasattr(type_hint, "__dataclass_fields__"):
+                # Resolve type hints to handle forward references from __future__ annotations
+                try:
+                    resolved_hints = get_type_hints(type_hint)
+                except Exception:
+                    resolved_hints = {}
+
+                # Use dataclass fields to get proper info including defaults and metadata
+                for field_name, field_info in type_hint.__dataclass_fields__.items():
+                    # Skip fields marked with serialize="omit" in metadata
+                    if field_info.metadata:
+                        # Check for mashumaro field_options
+                        if "serialize" in field_info.metadata:
+                            if field_info.metadata["serialize"] == "omit":
+                                continue
+
+                    # Use resolved type hint if available, otherwise fall back to field type
+                    field_type = resolved_hints.get(field_name, field_info.type)
+                    field_schema = _get_type_schema(field_type, definitions)
+
+                    # Add default value if present
+                    if field_info.default is not MISSING:
+                        field_schema["default"] = field_info.default
+                    elif (
+                        hasattr(field_info, "default_factory")
+                        and field_info.default_factory is not MISSING
+                    ):
+                        # Has a default factory - don't add anything, just skip
+                        pass
+
+                    properties[field_name] = field_schema
+
+                    # Check if field is required (not Optional and no default)
+                    has_default = field_info.default is not MISSING or (
+                        hasattr(field_info, "default_factory")
+                        and field_info.default_factory is not MISSING
+                    )
+                    is_optional = get_origin(field_type) in (
+                        Union,
+                        UnionType,
+                    ) and NoneType in get_args(field_type)
+                    if not has_default and not is_optional:
+                        required.append(field_name)
+            elif hasattr(type_hint, "__annotations__"):
+                # Fallback for non-dataclass types with annotations
+                for field_name, field_type in type_hint.__annotations__.items():
+                    properties[field_name] = _get_type_schema(field_type, definitions)
+                    # Check if field is required (not Optional)
+                    if not (
+                        get_origin(field_type) in (Union, UnionType)
+                        and NoneType in get_args(field_type)
+                    ):
+                        required.append(field_name)
+            else:
+                # Class without dataclass fields or annotations - treat as generic object
+                pass  # Will create empty properties
+
+            definitions[type_name] = {
+                "type": "object",
+                "properties": properties,
+            }
+            if required:
+                definitions[type_name]["required"] = required
+
+        return {"$ref": f"#/components/schemas/{type_name}"}
+
+    # Handle Any
+    if type_hint is Any:
+        return {"type": "object"}
+
+    # Fallback - for types we don't recognize, at least return a generic object type
+    return {"type": "object"}
+
+
+def _parse_docstring(  # noqa: PLR0915
+    func: Callable[..., Any],
+) -> tuple[str, str, dict[str, str]]:
+    """Parse docstring to extract summary, description and parameter descriptions.
+
+    Returns:
+        Tuple of (short_summary, full_description, param_descriptions)
+
+    Handles multiple docstring formats:
+    - reStructuredText (:param name: description)
+    - Google style (Args: section)
+    - NumPy style (Parameters section)
+    """
+    docstring = inspect.getdoc(func)
+    if not docstring:
+        return "", "", {}
+
+    lines = docstring.split("\n")
+    description_lines = []
+    param_descriptions = {}
+    current_section = "description"
+    current_param = None
+
+    for line in lines:
+        stripped = line.strip()
+
+        # Check for section headers
+        if stripped.lower() in ("args:", "arguments:", "parameters:", "params:"):
+            current_section = "params"
+            current_param = None
+            continue
+        if stripped.lower() in (
+            "returns:",
+            "return:",
+            "yields:",
+            "raises:",
+            "raises",
+            "examples:",
+            "example:",
+            "note:",
+            "notes:",
+            "see also:",
+            "warning:",
+            "warnings:",
+        ):
+            current_section = "other"
+            current_param = None
+            continue
+
+        # Parse :param style
+        if stripped.startswith(":param "):
+            current_section = "params"
+            parts = stripped[7:].split(":", 1)
+            if len(parts) == 2:
+                current_param = parts[0].strip()
+                desc = parts[1].strip()
+                if desc:
+                    param_descriptions[current_param] = desc
+            continue
+
+        if stripped.startswith((":type ", ":rtype", ":return")):
+            current_section = "other"
+            current_param = None
+            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"
+            if ":" in stripped and not stripped.startswith(" "):
+                # Likely a parameter definition
+                if "(" in stripped and ")" in stripped:
+                    # Format: param_name (type): description
+                    param_part = stripped.split(":")[0]
+                    param_name = param_part.split("(")[0].strip()
+                    desc_part = ":".join(stripped.split(":")[1:]).strip()
+                else:
+                    # Format: param_name: description
+                    parts = stripped.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
+            elif current_param and stripped:
+                # Continuation of previous parameter description
+                param_descriptions[current_param] = (
+                    param_descriptions.get(current_param, "") + " " + stripped
+                ).strip()
+            continue
+
+        # Collect description lines (only before params/returns sections)
+        if current_section == "description" and stripped:
+            description_lines.append(stripped)
+        elif current_section == "description" and not stripped and description_lines:
+            # Empty line in description - keep it for paragraph breaks
+            description_lines.append("")
+
+    # Join description lines, removing excessive empty lines
+    description = "\n".join(description_lines).strip()
+    # Collapse multiple empty lines into one
+    while "\n\n\n" in description:
+        description = description.replace("\n\n\n", "\n\n")
+
+    # Extract first sentence/line as summary
+    summary = ""
+    if description:
+        # Get first line or first sentence (whichever is shorter)
+        first_line = description.split("\n")[0]
+        # Try to get first sentence (ending with .)
+        summary = first_line.split(".")[0] + "." if "." in first_line else first_line
+
+    return summary, description, param_descriptions
+
+
+def generate_openapi_spec(
+    command_handlers: dict[str, APICommandHandler],
+    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)
+
+        # Build request body
+        request_body_properties = {}
+        request_body_required = []
+
+        for param_name, param in handler.signature.parameters.items():
+            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)
+
+        # Build response
+        return_type = handler.type_hints.get("return", Any)
+        response_schema = _get_type_schema(return_type, definitions)
+
+        # Build path item
+        path = f"/{command}"
+        paths[path] = {
+            "post": {
+                "summary": summary or command,
+                "description": description,
+                "operationId": command.replace("/", "_"),
+                "tags": [command.split("/")[0]] if "/" in command else ["general"],
+                "requestBody": {
+                    "required": True,
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "type": "object",
+                                "properties": request_body_properties,
+                                **(
+                                    {"required": request_body_required}
+                                    if request_body_required
+                                    else {}
+                                ),
+                            }
+                        }
+                    },
+                }
+                if request_body_properties
+                else None,
+                "responses": {
+                    "200": {
+                        "description": "Successful response",
+                        "content": {"application/json": {"schema": response_schema}},
+                    },
+                    "400": {"description": "Bad request - invalid parameters"},
+                    "500": {"description": "Internal server error"},
+                },
+            }
+        }
+
+        # Remove requestBody if empty
+        if not request_body_properties:
+            del paths[path]["post"]["requestBody"]
+
+    # Build OpenAPI spec
+    return {
+        "openapi": "3.0.0",
+        "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(),
+            "contact": {
+                "name": "Music Assistant",
+                "url": "https://music-assistant.io",
+            },
+        },
+        "servers": [{"url": server_url, "description": "Music Assistant Server"}],
+        "paths": paths,
+        "components": {"schemas": definitions},
+    }
+
+
+def generate_html_docs(  # noqa: PLR0915
+    command_handlers: dict[str, APICommandHandler],
+    server_url: str = "http://localhost:8095",
+    version: str = "1.0.0",
+) -> str:
+    """Generate HTML documentation from API command handlers."""
+    # 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))
+
+    # Start building HTML
+    html_parts = [
+        """<!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>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+                Oxygen, Ubuntu, Cantarell, sans-serif;
+            line-height: 1.6;
+            color: #333;
+            background: #f5f5f5;
+        }
+        .container {
+            max-width: 1200px;
+            margin: 0 auto;
+            padding: 20px;
+        }
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 40px 20px;
+            text-align: center;
+            margin-bottom: 30px;
+            border-radius: 8px;
+            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+        }
+        .header h1 {
+            font-size: 2.5em;
+            margin-bottom: 10px;
+        }
+        .header p {
+            font-size: 1.1em;
+            opacity: 0.9;
+        }
+        .intro {
+            background: white;
+            padding: 30px;
+            margin-bottom: 30px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }
+        .intro h2 {
+            color: #667eea;
+            margin-bottom: 15px;
+        }
+        .intro h3 {
+            color: #764ba2;
+            margin: 20px 0 10px 0;
+        }
+        .intro pre {
+            background: #f8f9fa;
+            padding: 15px;
+            border-radius: 4px;
+            overflow-x: auto;
+            border-left: 4px solid #667eea;
+        }
+        .intro code {
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.9em;
+        }
+        .category {
+            background: white;
+            margin-bottom: 30px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+            overflow: hidden;
+        }
+        .category-header {
+            background: #667eea;
+            color: white;
+            padding: 20px;
+            font-size: 1.5em;
+            font-weight: bold;
+            text-transform: capitalize;
+        }
+        .command {
+            border-bottom: 1px solid #e0e0e0;
+            padding: 20px;
+        }
+        .command:last-child {
+            border-bottom: none;
+        }
+        .command-name {
+            font-size: 1.2em;
+            font-weight: bold;
+            color: #667eea;
+            font-family: 'Monaco', 'Courier New', monospace;
+            margin-bottom: 10px;
+        }
+        .command-description {
+            color: #666;
+            margin-bottom: 15px;
+        }
+        .params, .returns {
+            margin-top: 15px;
+        }
+        .params h4, .returns h4 {
+            color: #764ba2;
+            margin-bottom: 10px;
+            font-size: 1em;
+        }
+        .param {
+            background: #f8f9fa;
+            padding: 10px;
+            margin: 5px 0;
+            border-radius: 4px;
+            border-left: 3px solid #667eea;
+        }
+        .param-name {
+            font-weight: bold;
+            color: #333;
+            font-family: 'Monaco', 'Courier New', monospace;
+        }
+        .param-type {
+            color: #764ba2;
+            font-style: italic;
+            font-size: 0.9em;
+        }
+        .param-required {
+            color: #e74c3c;
+            font-size: 0.85em;
+            font-weight: bold;
+        }
+        .param-optional {
+            color: #95a5a6;
+            font-size: 0.85em;
+        }
+        .param-description {
+            color: #666;
+            margin-top: 5px;
+        }
+        .return-type {
+            background: #f8f9fa;
+            padding: 10px;
+            border-radius: 4px;
+            border-left: 3px solid #764ba2;
+            font-family: 'Monaco', 'Courier New', monospace;
+            color: #764ba2;
+        }
+        .nav {
+            background: white;
+            padding: 20px;
+            margin-bottom: 30px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }
+        .nav h3 {
+            color: #667eea;
+            margin-bottom: 15px;
+        }
+        .nav ul {
+            list-style: none;
+        }
+        .nav li {
+            margin: 5px 0;
+        }
+        .nav a {
+            color: #667eea;
+            text-decoration: none;
+            text-transform: capitalize;
+        }
+        .nav a:hover {
+            text-decoration: underline;
+        }
+        .download-link {
+            display: inline-block;
+            background: #667eea;
+            color: white;
+            padding: 10px 20px;
+            border-radius: 4px;
+            text-decoration: none;
+            margin-top: 10px;
+        }
+        .download-link:hover {
+            background: #764ba2;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>Music Assistant API Documentation</h1>
+            <p>Version """,
+        version,
+        """</p>
+        </div>
+
+        <div class="intro">
+            <h2>Getting Started</h2>
+            <p>Music Assistant provides two ways to interact with the API:</p>
+
+            <h3>🔌 WebSocket API (Recommended)</h3>
+            <p>
+                The WebSocket API provides full access to all commands
+                and <strong>real-time event updates</strong>.
+            </p>
+            <ul style="margin-left: 20px; margin-top: 10px;">
+                <li><strong>Endpoint:</strong> <code>ws://""",
+        server_url.replace("http://", "").replace("https://", ""),
+        """/ws</code></li>
+                <li>
+                    <strong>Best for:</strong> Applications that need live
+                    updates and real-time communication
+                </li>
+                <li>
+                    <strong>Bonus:</strong> When connected, you automatically
+                    receive event messages for state changes
+                </li>
+            </ul>
+            <p style="margin-top: 10px;"><strong>Sending commands:</strong></p>
+            <pre><code>{
+  "message_id": "unique-id-123",
+  "command": "players/all",
+  "args": {}
+}</code></pre>
+            <p style="margin-top: 10px;"><strong>Receiving events:</strong></p>
+            <p>
+                Once connected, you will automatically receive event messages
+                whenever something changes:
+            </p>
+            <pre><code>{
+  "event": "player_updated",
+  "data": {
+    "player_id": "player_123",
+    ...player data...
+  }
+}</code></pre>
+
+            <h3>🌐 REST API (Simple)</h3>
+            <p>
+                The REST API provides a simple HTTP interface for
+                executing commands.
+            </p>
+            <ul style="margin-left: 20px; margin-top: 10px;">
+                <li><strong>Endpoint:</strong> <code>POST """,
+        server_url,
+        """/api</code></li>
+                <li>
+                    <strong>Best for:</strong> Simple, incidental commands
+                    without need for real-time updates
+                </li>
+            </ul>
+            <p style="margin-top: 10px;"><strong>Example request:</strong></p>
+            <pre><code>{
+  "command": "players/all",
+  "args": {}
+}</code></pre>
+
+            <h3>📥 OpenAPI Specification</h3>
+            <p>Download the OpenAPI 3.0 specification for automated client generation:</p>
+            <a href="/openapi.json" class="download-link">Download openapi.json</a>
+
+            <h3>🚀 Interactive API Explorers</h3>
+            <p>
+                Try out the API interactively with our API explorers.
+                Test endpoints, see live responses, and explore the full API:
+            </p>
+            <div style="margin-top: 15px;">
+                <a href="/api-explorer" class="download-link" style="margin-right: 10px;">
+                    Swagger UI Explorer
+                </a>
+                <a href="/api-docs" class="download-link">
+                    ReDoc Documentation
+                </a>
+            </div>
+
+            <h3>📡 WebSocket Events</h3>
+            <p>
+                When connected via WebSocket, you automatically receive
+                real-time event notifications:
+            </p>
+            <div style="margin-top: 15px; margin-left: 20px;">
+                <strong>Player Events:</strong>
+                <ul style="margin-left: 20px;">
+                    <li><code>player_added</code> - New player discovered</li>
+                    <li><code>player_updated</code> - Player state changed</li>
+                    <li><code>player_removed</code> - Player disconnected</li>
+                    <li><code>player_config_updated</code> - Player settings changed</li>
+                </ul>
+
+                <strong style="margin-top: 10px; display: block;">Queue Events:</strong>
+                <ul style="margin-left: 20px;">
+                    <li><code>queue_added</code> - New queue created</li>
+                    <li><code>queue_updated</code> - Queue state changed</li>
+                    <li><code>queue_items_updated</code> - Queue content changed</li>
+                    <li><code>queue_time_updated</code> - Playback position updated</li>
+                </ul>
+
+                <strong style="margin-top: 10px; display: block;">Library Events:</strong>
+                <ul style="margin-left: 20px;">
+                    <li><code>media_item_added</code> - New media added to library</li>
+                    <li><code>media_item_updated</code> - Media metadata updated</li>
+                    <li><code>media_item_deleted</code> - Media removed from library</li>
+                    <li><code>media_item_played</code> - Media playback started</li>
+                </ul>
+
+                <strong style="margin-top: 10px; display: block;">System Events:</strong>
+                <ul style="margin-left: 20px;">
+                    <li><code>providers_updated</code> - Provider status changed</li>
+                    <li><code>sync_tasks_updated</code> - Sync progress updated</li>
+                    <li><code>application_shutdown</code> - Server shutting down</li>
+                </ul>
+            </div>
+        </div>
+
+        <div class="nav">
+            <h3>Quick Navigation</h3>
+            <ul>
+""",
+    ]
+
+    # Add navigation links
+    for category in sorted(categories.keys()):
+        html_parts.append(
+            f'                <li><a href="#{category}">{category}</a> '
+            f"({len(categories[category])} commands)</li>\n"
+        )
+
+    html_parts.append(
+        """            </ul>
+        </div>
+"""
+    )
+
+    # Add commands by category
+    for category, commands in sorted(categories.items()):
+        html_parts.append(f'        <div class="category" id="{category}">\n')
+        html_parts.append(f'            <div class="category-header">{category}</div>\n')
+
+        for command, handler in commands:
+            _, description, param_descriptions = _parse_docstring(handler.target)
+
+            html_parts.append('            <div class="command">\n')
+            html_parts.append(f'                <div class="command-name">{command}</div>\n')
+
+            if description:
+                html_parts.append(
+                    f'                <div class="command-description">{description}</div>\n'
+                )
+
+            # Parameters
+            params_html = []
+            for param_name, param in handler.signature.parameters.items():
+                if param_name == "self":
+                    continue
+
+                param_type = handler.type_hints.get(param_name, Any)
+                is_required = param.default is inspect.Parameter.empty
+                param_desc = param_descriptions.get(param_name, "")
+
+                # Format type name
+                type_name = _format_type_name(param_type)
+                if get_origin(param_type):
+                    origin = get_origin(param_type)
+                    args = get_args(param_type)
+                    if origin is Union or origin is UnionType:
+                        type_name = " | ".join(_format_type_name(arg) for arg in args)
+                    elif origin in (list, tuple):
+                        if args:
+                            inner_type = _format_type_name(args[0])
+                            type_name = f"{origin.__name__}[{inner_type}]"
+                    elif origin is dict:
+                        if len(args) == 2:
+                            key_type = _format_type_name(args[0])
+                            val_type = _format_type_name(args[1])
+                            type_name = f"dict[{key_type}, {val_type}]"
+
+                required_badge = (
+                    '<span class="param-required">required</span>'
+                    if is_required
+                    else '<span class="param-optional">optional</span>'
+                )
+
+                # Format default value
+                default_str = ""
+                if not is_required and param.default is not None:
+                    try:
+                        if isinstance(param.default, str):
+                            default_str = f' = "{param.default}"'
+                        elif isinstance(param.default, Enum):
+                            default_str = f" = {param.default.value}"
+                        elif isinstance(param.default, (int, float, bool, list, dict)):
+                            default_str = f" = {param.default}"
+                    except Exception:  # noqa: S110
+                        pass  # Can't serialize, skip default
+
+                params_html.append(
+                    f'                    <div class="param">\n'
+                    f'                        <span class="param-name">{param_name}</span>\n'
+                    f'                        <span class="param-type">'
+                    f"({type_name}{default_str})</span>\n"
+                    f"                        {required_badge}\n"
+                )
+                if param_desc:
+                    params_html.append(
+                        f'                        <div class="param-description">'
+                        f"{param_desc}</div>\n"
+                    )
+                params_html.append("                    </div>\n")
+
+            if params_html:
+                html_parts.append('                <div class="params">\n')
+                html_parts.append("                    <h4>Parameters</h4>\n")
+                html_parts.extend(params_html)
+                html_parts.append("                </div>\n")
+
+            # Return type
+            return_type = handler.type_hints.get("return", Any)
+            if return_type and return_type is not NoneType:
+                type_name = _format_type_name(return_type)
+                if get_origin(return_type):
+                    origin = get_origin(return_type)
+                    args = get_args(return_type)
+                    if origin in (list, tuple) and args:
+                        inner_type = _format_type_name(args[0])
+                        type_name = f"{origin.__name__}[{inner_type}]"
+                    elif origin is Union or origin is UnionType:
+                        type_name = " | ".join(_format_type_name(arg) for arg in args)
+
+                html_parts.append('                <div class="returns">\n')
+                html_parts.append("                    <h4>Returns</h4>\n")
+                html_parts.append(
+                    f'                    <div class="return-type">{type_name}</div>\n'
+                )
+                html_parts.append("                </div>\n")
+
+            html_parts.append("            </div>\n")
+
+        html_parts.append("        </div>\n")
+
+    html_parts.append(
+        """    </div>
+</body>
+</html>
+"""
+    )
+
+    return "".join(html_parts)
diff --git a/music_assistant/helpers/resources/api_docs.html b/music_assistant/helpers/resources/api_docs.html
new file mode 100644 (file)
index 0000000..ee3ee26
--- /dev/null
@@ -0,0 +1,487 @@
+<!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>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+                Oxygen, Ubuntu, Cantarell, sans-serif;
+            line-height: 1.7;
+            color: #2c3e50;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+        .container {
+            max-width: 1000px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 16px;
+            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+            overflow: hidden;
+        }
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 60px 40px;
+            text-align: center;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 20px;
+        }
+        .header .logo {
+            width: 80px;
+            height: 80px;
+        }
+        .header h1 {
+            font-size: 3em;
+            margin: 0;
+            font-weight: 700;
+        }
+        .header .version {
+            font-size: 1.2em;
+            opacity: 0.9;
+            font-weight: 300;
+        }
+        .content {
+            padding: 50px 40px;
+        }
+        .section {
+            margin-bottom: 50px;
+        }
+        .section h2 {
+            color: #667eea;
+            font-size: 2em;
+            margin-bottom: 20px;
+            padding-bottom: 10px;
+            border-bottom: 3px solid #667eea;
+        }
+        .section h3 {
+            color: #764ba2;
+            font-size: 1.5em;
+            margin: 30px 0 15px 0;
+        }
+        .section p {
+            margin-bottom: 15px;
+            font-size: 1.05em;
+            line-height: 1.8;
+        }
+        .api-boxes {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+            gap: 25px;
+            margin: 30px 0;
+        }
+        .api-box {
+            background: #f8f9fa;
+            padding: 30px;
+            border-radius: 12px;
+            border-left: 5px solid #667eea;
+            transition: transform 0.2s, box-shadow 0.2s;
+        }
+        .api-box:hover {
+            transform: translateY(-5px);
+            box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
+        }
+        .api-box h4 {
+            color: #667eea;
+            font-size: 1.3em;
+            margin-bottom: 12px;
+        }
+        .api-box p {
+            color: #666;
+            margin-bottom: 20px;
+            font-size: 0.95em;
+        }
+        .api-box .btn {
+            display: inline-block;
+            background: #667eea;
+            color: white;
+            padding: 12px 24px;
+            border-radius: 6px;
+            text-decoration: none;
+            font-weight: 500;
+            transition: background 0.3s;
+        }
+        .api-box .btn:hover {
+            background: #764ba2;
+        }
+        .code-block {
+            background: #2d2d2d;
+            color: #f8f8f2;
+            padding: 25px;
+            border-radius: 8px;
+            overflow-x: auto;
+            margin: 20px 0;
+            font-family: 'Monaco', 'Courier New', monospace;
+            font-size: 0.9em;
+            line-height: 1.6;
+            white-space: pre-wrap;
+        }
+        .code-block .comment {
+            color: #75715e;
+        }
+        .code-block .string {
+            color: #e6db74;
+        }
+        .code-block .keyword {
+            color: #66d9ef;
+        }
+        .code-block .number {
+            color: #ae81ff;
+        }
+        .highlight {
+            background: #fff3cd;
+            padding: 2px 6px;
+            border-radius: 3px;
+            font-weight: 500;
+        }
+        .info-box {
+            background: #e7f3ff;
+            border-left: 4px solid #2196F3;
+            padding: 20px;
+            margin: 20px 0;
+            border-radius: 6px;
+        }
+        .info-box strong {
+            color: #2196F3;
+        }
+        .events-list {
+            background: #f8f9fa;
+            padding: 25px;
+            border-radius: 8px;
+            margin: 20px 0;
+        }
+        .events-list h4 {
+            color: #667eea;
+            margin-bottom: 15px;
+            font-size: 1.2em;
+        }
+        .events-list ul {
+            list-style: none;
+            padding-left: 0;
+        }
+        .events-list li {
+            padding: 8px 0;
+            border-bottom: 1px solid #e0e0e0;
+        }
+        .events-list li:last-child {
+            border-bottom: none;
+        }
+        .events-list code {
+            background: #667eea;
+            color: white;
+            padding: 3px 8px;
+            border-radius: 4px;
+            font-size: 0.9em;
+            font-weight: 500;
+        }
+        .client-links {
+            display: flex;
+            gap: 15px;
+            margin: 20px 0;
+            flex-wrap: wrap;
+        }
+        .client-link {
+            background: #764ba2;
+            color: white;
+            padding: 12px 24px;
+            border-radius: 6px;
+            text-decoration: none;
+            font-weight: 500;
+            transition: background 0.3s;
+            display: inline-flex;
+            align-items: center;
+            gap: 8px;
+        }
+        .client-link:hover {
+            background: #667eea;
+        }
+        .footer {
+            background: #f8f9fa;
+            padding: 30px 40px;
+            text-align: center;
+            color: #666;
+            border-top: 1px solid #e0e0e0;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <svg class="logo" viewBox="0 0 240 240" fill="none">
+                <g clip-path="url(#clipPath19)" transform="translate(0,2)">
+                    <path d="m 109.394,4.3814 c 5.848,-5.84187 15.394,-5.84187 21.212,0 l 98.788,98.8876 c 5.848,5.842 10.606,17.374 10.606,25.638 v 90.11 l -0.005,0.356 c -0.206,8.086 -6.881,14.628 -14.995,14.628 H 15 C 6.75759,234.001 2.40473e-5,227.22 0,218.987 v -90.11 c 1.20331e-4,-8.264 4.78834,-19.796 10.6064,-25.638 z" fill="#f2f4f9"/>
+                    <path d="m 109.394,4.3814 c 5.848,-5.84187 15.394,-5.84187 21.212,0 l 98.788,98.8876 c 5.848,5.842 10.606,17.374 10.606,25.638 v 90.11 l -0.005,0.356 c -0.206,8.086 -6.881,14.628 -14.995,14.628 H 15 C 6.75759,234.001 2.40473e-5,227.22 0,218.987 v -90.11 c 1.20331e-4,-8.264 4.78834,-19.796 10.6064,-25.638 z M 36,120.001 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.5817,-8 -8,-8 z m 32,0 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.5817,-8 -8,-8 z m 32,0 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.582,-8 -8,-8 z m 58.393,0.426 c -4.193,-1.395 -8.722,0.873 -10.118,5.065 l -26.796,80.509 h 16.863 l 25.114,-75.457 c 1.395,-4.192 -0.872,-8.721 -5.063,-10.117 z m 30.315,5.065 c -1.395,-4.192 -5.925,-6.46 -10.117,-5.065 -4.192,1.396 -6.46,5.925 -5.065,10.117 l 25.116,75.457 h 16.862 z" fill="#18bcf2"/>
+                </g>
+                <defs>
+                    <clipPath id="clipPath19" clipPathUnits="userSpaceOnUse">
+                        <rect width="239.88728" height="239.55457" x="0.14213564" y="0.010407645"/>
+                    </clipPath>
+                </defs>
+            </svg>
+            <h1>Music Assistant API</h1>
+            <div class="version">Version {VERSION}</div>
+        </div>
+
+        <div class="content">
+            <div class="section">
+                <h2>Welcome</h2>
+                <p>
+                    Music Assistant provides a powerful API to control your music library,
+                    manage players, and stream audio. Whether you're building a custom interface,
+                    integrating with home automation, or creating a music app, our API gives you
+                    complete control.
+                </p>
+                <p>
+                    This documentation will help you get started quickly with examples and best practices.
+                </p>
+            </div>
+
+            <div class="section">
+                <h2>API Explorers</h2>
+                <p>
+                    Interactive documentation where you can test all API endpoints directly in your browser:
+                </p>
+                <div class="api-boxes">
+                    <div class="api-box">
+                        <h4>📘 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>
+                    </div>
+                    <div class="api-box">
+                        <h4>📗 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>
+                    </div>
+                    <div class="api-box">
+                        <h4>📄 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>
+                    </div>
+                </div>
+            </div>
+
+            <div class="section">
+                <h2>Quick Start</h2>
+
+                <h3>WebSocket API (Recommended)</h3>
+                <p>
+                    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="code-block">
+<span class="comment"># Connect to WebSocket</span>
+ws://{SERVER_HOST}/ws
+
+<span class="comment"># Send a command</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>,
+  <span class="string">"args"</span>: {}
+}
+
+<span class="comment"># Receive response</span>
+{
+  <span class="string">"message_id"</span>: <span class="string">"unique-id-123"</span>,
+  <span class="string">"result"</span>: [<span class="comment">...player data...</span>]
+}
+
+<span class="comment"># Receive automatic events</span>
+{
+  <span class="string">"event"</span>: <span class="string">"player_updated"</span>,
+  <span class="string">"data"</span>: {<span class="comment">...updated player...</span>}
+}
+                </div>
+
+                <h3>REST API (Simple)</h3>
+                <p>
+                    For simple, one-off commands without needing real-time updates.
+                </p>
+                <div class="code-block">
+<span class="comment"># Get all players</span>
+curl -X POST {BASE_URL}/api \
+  -H <span class="string">"Content-Type: application/json"</span> \
+  -d <span class="string">'{
+    "command": "players/all",
+    "args": {}
+  }'</span>
+
+<span class="comment"># Play media on a player</span>
+curl -X POST {BASE_URL}/api \
+  -H <span class="string">"Content-Type: application/json"</span> \
+  -d <span class="string">'{
+    "command": "player_queues/play_media",
+    "args": {
+      "queue_id": "player_123",
+      "media": ["library://track/456"]
+    }
+  }'</span>
+
+<span class="comment"># Get server info</span>
+curl {BASE_URL}/info
+                </div>
+            </div>
+
+            <div class="section">
+                <h2>WebSocket Events</h2>
+                <p>
+                    When connected via WebSocket, you automatically receive real-time event notifications
+                    for all state changes. No polling required!
+                </p>
+
+                <div class="events-list">
+                    <h4>🔊 Player Events</h4>
+                    <ul>
+                        <li><code>player_added</code> - New player discovered</li>
+                        <li><code>player_updated</code> - Player state changed</li>
+                        <li><code>player_removed</code> - Player disconnected</li>
+                        <li><code>player_config_updated</code> - Settings changed</li>
+                    </ul>
+                </div>
+
+                <div class="events-list">
+                    <h4>🎵 Queue Events</h4>
+                    <ul>
+                        <li><code>queue_added</code> - New queue created</li>
+                        <li><code>queue_updated</code> - Queue state changed</li>
+                        <li><code>queue_items_updated</code> - Content changed</li>
+                        <li><code>queue_time_updated</code> - Playback position updated</li>
+                    </ul>
+                </div>
+
+                <div class="events-list">
+                    <h4>📚 Library Events</h4>
+                    <ul>
+                        <li><code>media_item_added</code> - New media added</li>
+                        <li><code>media_item_updated</code> - Metadata updated</li>
+                        <li><code>media_item_deleted</code> - Media removed</li>
+                        <li><code>media_item_played</code> - Playback started</li>
+                    </ul>
+                </div>
+
+                <div class="events-list">
+                    <h4>⚙️ System Events</h4>
+                    <ul>
+                        <li><code>providers_updated</code> - Provider status changed</li>
+                        <li><code>sync_tasks_updated</code> - Sync progress updated</li>
+                        <li><code>application_shutdown</code> - Server shutting down</li>
+                    </ul>
+                </div>
+            </div>
+
+            <div class="section">
+                <h2>Client Libraries</h2>
+                <p>
+                    Don't want to implement the API from scratch? Use our official client libraries:
+                </p>
+
+                <h3>Python Client</h3>
+                <p>
+                    Official Python client library with full type hints and async support:
+                </p>
+                <div class="code-block">
+<span class="comment"># Install</span>
+pip install music-assistant-client
+
+<span class="comment"># Usage</span>
+<span class="keyword">from</span> music_assistant_client <span class="keyword">import</span> MusicAssistantClient
+
+<span class="keyword">async with</span> MusicAssistantClient(<span class="string">"{SERVER_HOST}"</span>) <span class="keyword">as</span> client:
+    <span class="comment"># Get all players</span>
+    players = <span class="keyword">await</span> client.get_players()
+
+    <span class="comment"># Play media</span>
+    <span class="keyword">await</span> client.play_media(
+        queue_id=<span class="string">"player_123"</span>,
+        media=[<span class="string">"library://track/456"</span>]
+    )
+                </div>
+                <div class="client-links">
+                    <a href="https://github.com/music-assistant/client" class="client-link">
+                        📦 GitHub Repository
+                    </a>
+                    <a href="https://pypi.org/project/music-assistant-client/" class="client-link">
+                        🐍 PyPI Package
+                    </a>
+                </div>
+
+                <h3>TypeScript/JavaScript</h3>
+                <p>
+                    Reference implementation in the Music Assistant frontend:
+                </p>
+                <div class="code-block">
+<span class="comment">// Example from frontend code</span>
+<span class="keyword">import</span> { MusicAssistantApi } <span class="keyword">from</span> <span class="string">'./api'</span>;
+
+<span class="keyword">const</span> api = <span class="keyword">new</span> MusicAssistantApi(<span class="string">'{SERVER_HOST}'</span>);
+
+<span class="comment">// Connect</span>
+<span class="keyword">await</span> api.connect();
+
+<span class="comment">// Subscribe to events</span>
+api.subscribe(<span class="string">'player_updated'</span>, (event) => {
+  console.log(<span class="string">'Player updated:'</span>, event.data);
+});
+
+<span class="comment">// Call commands</span>
+<span class="keyword">const</span> players = <span class="keyword">await</span> api.getPlayers();
+                </div>
+                <div class="client-links">
+                    <a href="https://github.com/music-assistant/frontend/tree/main/src/plugins/api" class="client-link">
+                        📦 Frontend API Code
+                    </a>
+                </div>
+                <div class="info-box">
+                    <strong>Coming Soon:</strong> A dedicated TypeScript client library is in development!
+                    For now, you can use the frontend's API implementation as a reference.
+                </div>
+            </div>
+
+            <div class="section">
+                <h2>Best Practices</h2>
+                <p><strong>✓ Do:</strong></p>
+                <ul style="margin-left: 30px; margin-bottom: 15px;">
+                    <li>Use WebSocket API for real-time applications</li>
+                    <li>Handle connection drops and reconnect automatically</li>
+                    <li>Subscribe to relevant events instead of polling</li>
+                    <li>Use unique message IDs for WebSocket commands</li>
+                    <li>Implement proper error handling</li>
+                </ul>
+                <p><strong>✗ Don't:</strong></p>
+                <ul style="margin-left: 30px;">
+                    <li>Poll the REST API frequently for updates (use WebSocket events instead)</li>
+                    <li>Send commands without waiting for previous responses</li>
+                    <li>Ignore error responses</li>
+                    <li>Hardcode server URLs (make them configurable)</li>
+                </ul>
+            </div>
+
+            <div class="section">
+                <h2>Authentication</h2>
+                <div class="info-box">
+                    <strong>Note:</strong> Authentication is not yet implemented but will be added
+                    in a future release. For now, ensure your Music Assistant server is not directly
+                    exposed to the internet. Use a VPN or reverse proxy for secure access.
+                </div>
+            </div>
+        </div>
+
+        <div class="footer">
+            <p>
+                Music Assistant {VERSION} •
+                <a href="https://music-assistant.io" style="color: #667eea;">music-assistant.io</a> •
+                <a href="https://github.com/music-assistant" style="color: #667eea;">GitHub</a>
+            </p>
+        </div>
+    </div>
+</body>
+</html>
diff --git a/music_assistant/helpers/resources/redoc_ui.html b/music_assistant/helpers/resources/redoc_ui.html
new file mode 100644 (file)
index 0000000..ed67b29
--- /dev/null
@@ -0,0 +1,28 @@
+<!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>
diff --git a/music_assistant/helpers/resources/swagger_ui.html b/music_assistant/helpers/resources/swagger_ui.html
new file mode 100644 (file)
index 0000000..3eed9dc
--- /dev/null
@@ -0,0 +1,61 @@
+<!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 Explorer</title>
+    <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css">
+    <style>
+        body {
+            margin: 0;
+            padding: 0;
+        }
+        .topbar {
+            display: none;
+        }
+        .swagger-ui .info {
+            margin: 30px 0;
+        }
+        .swagger-ui .info .title {
+            font-size: 2.5em;
+        }
+    </style>
+</head>
+<body>
+    <div id="swagger-ui"></div>
+    <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
+    <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({
+                url: openapiUrl,
+                dom_id: '#swagger-ui',
+                deepLinking: true,
+                presets: [
+                    SwaggerUIBundle.presets.apis,
+                    SwaggerUIStandalonePreset
+                ],
+                plugins: [
+                    SwaggerUIBundle.plugins.DownloadUrl
+                ],
+                layout: "StandaloneLayout",
+                defaultModelsExpandDepth: 1,
+                defaultModelExpandDepth: 3,
+                docExpansion: "list",
+                filter: true,
+                showRequestHeaders: true,
+                tryItOutEnabled: true,
+                persistAuthorization: true,
+                displayRequestDuration: true,
+                operationsSorter: "alpha",
+                tagsSorter: "alpha"
+            });
+
+            window.ui = ui;
+        }
+    </script>
+</body>
+</html>