music-assistant-server

48.8 KBPY
api_docs.py
48.8 KB1,208 lines • python
1"""Helpers for generating API documentation and OpenAPI specifications."""
2
3from __future__ import annotations
4
5import collections.abc
6import inspect
7import re
8from collections.abc import Callable
9from dataclasses import MISSING
10from datetime import datetime
11from enum import Enum
12from types import NoneType, UnionType
13from typing import Any, Union, get_args, get_origin, get_type_hints
14
15from music_assistant_models.player import Player as PlayerState
16
17from music_assistant.helpers.api import APICommandHandler
18
19
20def _format_type_name(type_hint: Any) -> str:
21    """Format a type hint as a user-friendly string, using JSON types instead of Python types."""
22    if type_hint is NoneType or type_hint is type(None):
23        return "null"
24
25    # Handle internal Player model - replace with PlayerState
26    if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
27        if (
28            hasattr(type_hint, "__module__")
29            and type_hint.__module__ == "music_assistant.models.player"
30        ):
31            return "PlayerState"
32
33    # Handle PluginSource - replace with PlayerSource (parent type)
34    if hasattr(type_hint, "__name__") and type_hint.__name__ == "PluginSource":
35        if (
36            hasattr(type_hint, "__module__")
37            and type_hint.__module__ == "music_assistant.models.plugin"
38        ):
39            return "PlayerSource"
40
41    # Map Python types to JSON types
42    type_name_mapping = {
43        "str": "string",
44        "int": "integer",
45        "float": "number",
46        "bool": "boolean",
47        "dict": "object",
48        "list": "array",
49        "tuple": "array",
50        "set": "array",
51        "frozenset": "array",
52        "Sequence": "array",
53        "UniqueList": "array",
54        "None": "null",
55    }
56
57    if hasattr(type_hint, "__name__"):
58        type_name = str(type_hint.__name__)
59        return type_name_mapping.get(type_name, type_name)
60
61    type_str = str(type_hint).replace("NoneType", "null")
62    # Replace Python types with JSON types in complex type strings
63    for python_type, json_type in type_name_mapping.items():
64        type_str = type_str.replace(python_type, json_type)
65    return type_str
66
67
68def _generate_type_alias_description(type_alias: Any, alias_name: str) -> str:
69    """Generate a human-readable description of a type alias from its definition.
70
71    :param type_alias: The type alias to describe (e.g., ConfigValueType)
72    :param alias_name: The name of the alias for display
73    :return: A human-readable description string
74    """
75    # Get the union args
76    args = get_args(type_alias)
77    if not args:
78        return f"Type alias for {alias_name}."
79
80    # Convert each type to a readable name
81    type_names = []
82    for arg in args:
83        origin = get_origin(arg)
84        if origin in (list, tuple):
85            # Handle list types
86            inner_args = get_args(arg)
87            if inner_args:
88                inner_type = inner_args[0]
89                if inner_type is bool:
90                    type_names.append("array of boolean")
91                elif inner_type is int:
92                    type_names.append("array of integer")
93                elif inner_type is float:
94                    type_names.append("array of number")
95                elif inner_type is str:
96                    type_names.append("array of string")
97                else:
98                    type_names.append(
99                        f"array of {getattr(inner_type, '__name__', str(inner_type))}"
100                    )
101            else:
102                type_names.append("array")
103        elif arg is type(None) or arg is NoneType:
104            type_names.append("null")
105        elif arg is bool:
106            type_names.append("boolean")
107        elif arg is int:
108            type_names.append("integer")
109        elif arg is float:
110            type_names.append("number")
111        elif arg is str:
112            type_names.append("string")
113        elif hasattr(arg, "__name__"):
114            type_names.append(arg.__name__)
115        else:
116            type_names.append(str(arg))
117
118    # Format the list nicely
119    if len(type_names) == 1:
120        types_str = type_names[0]
121    elif len(type_names) == 2:
122        types_str = f"{type_names[0]} or {type_names[1]}"
123    else:
124        types_str = f"{', '.join(type_names[:-1])}, or {type_names[-1]}"
125
126    return f"Type alias for {alias_name.lower()} types. Can be {types_str}."
127
128
129def _get_type_schema(  # noqa: PLR0911, PLR0915
130    type_hint: Any, definitions: dict[str, Any]
131) -> dict[str, Any]:
132    """Convert a Python type hint to an OpenAPI schema."""
133    # Check if type_hint matches a type alias that was expanded by get_type_hints()
134    # Import type aliases to compare against
135    from music_assistant_models.config_entries import (  # noqa: PLC0415
136        ConfigValueType as config_value_type,  # noqa: N813
137    )
138    from music_assistant_models.media_items import (  # noqa: PLC0415
139        MediaItemType as media_item_type,  # noqa: N813
140    )
141
142    if type_hint == config_value_type:
143        # This is the expanded ConfigValueType, treat it as the type alias
144        return _get_type_schema("ConfigValueType", definitions)
145    if type_hint == media_item_type:
146        # This is the expanded MediaItemType, treat it as the type alias
147        return _get_type_schema("MediaItemType", definitions)
148
149    # Handle string type hints from __future__ annotations
150    if isinstance(type_hint, str):
151        # Handle simple primitive type names
152        if type_hint in ("str", "string"):
153            return {"type": "string"}
154        if type_hint in ("int", "integer"):
155            return {"type": "integer"}
156        if type_hint in ("float", "number"):
157            return {"type": "number"}
158        if type_hint in ("bool", "boolean"):
159            return {"type": "boolean"}
160
161        # Special handling for type aliases - create proper schema definitions
162        if type_hint == "ConfigValueType":
163            if "ConfigValueType" not in definitions:
164                from music_assistant_models.config_entries import (  # noqa: PLC0415
165                    ConfigValueType as config_value_type,  # noqa: N813
166                )
167
168                # Dynamically create oneOf schema with description from the actual type
169                cvt_args = get_args(config_value_type)
170                definitions["ConfigValueType"] = {
171                    "description": _generate_type_alias_description(
172                        config_value_type, "configuration value"
173                    ),
174                    "oneOf": [_get_type_schema(arg, definitions) for arg in cvt_args],
175                }
176            return {"$ref": "#/components/schemas/ConfigValueType"}
177
178        if type_hint == "MediaItemType":
179            if "MediaItemType" not in definitions:
180                from music_assistant_models.media_items import (  # noqa: PLC0415
181                    MediaItemType as media_item_type,  # noqa: N813
182                )
183
184                # Dynamically create oneOf schema with description from the actual type
185                mit_origin = get_origin(media_item_type)
186                if mit_origin in (Union, UnionType):
187                    mit_args = get_args(media_item_type)
188                    definitions["MediaItemType"] = {
189                        "description": _generate_type_alias_description(
190                            media_item_type, "media item"
191                        ),
192                        "oneOf": [_get_type_schema(arg, definitions) for arg in mit_args],
193                    }
194                else:
195                    definitions["MediaItemType"] = _get_type_schema(media_item_type, definitions)
196            return {"$ref": "#/components/schemas/MediaItemType"}
197
198        # Handle PluginSource - replace with PlayerSource (parent type)
199        if type_hint == "PluginSource":
200            return _get_type_schema("PlayerSource", definitions)
201
202        # Check if it looks like a simple class name (no special chars, starts with uppercase)
203        # Examples: "PlayerType", "DeviceInfo", "PlaybackState"
204        # Exclude generic types like "Any", "Union", "Optional", etc.
205        excluded_types = {"Any", "Union", "Optional", "List", "Dict", "Tuple", "Set"}
206        if type_hint.isidentifier() and type_hint[0].isupper() and type_hint not in excluded_types:
207            # Create a schema reference for this type
208            if type_hint not in definitions:
209                definitions[type_hint] = {"type": "object"}
210            return {"$ref": f"#/components/schemas/{type_hint}"}
211
212        # If it's "Any", return generic object without creating a schema
213        if type_hint == "Any":
214            return {"type": "object"}
215
216        # For complex type expressions like "str | None", "list[str]", return generic object
217        return {"type": "object"}
218
219    # Handle None type
220    if type_hint is NoneType or type_hint is type(None):
221        return {"type": "null"}
222
223    # Handle internal Player model - replace with external PlayerState
224    if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
225        # Check if this is the internal Player (from music_assistant.models.player)
226        if (
227            hasattr(type_hint, "__module__")
228            and type_hint.__module__ == "music_assistant.models.player"
229        ):
230            # Replace with PlayerState from music_assistant_models
231            return _get_type_schema(PlayerState, definitions)
232
233    # Handle PluginSource - replace with PlayerSource (parent type)
234    if hasattr(type_hint, "__name__") and type_hint.__name__ == "PluginSource":
235        # Check if this is PluginSource from music_assistant.models.plugin
236        if (
237            hasattr(type_hint, "__module__")
238            and type_hint.__module__ == "music_assistant.models.plugin"
239        ):
240            # Replace with PlayerSource from music_assistant.models.player
241            from music_assistant.models.player import PlayerSource  # noqa: PLC0415
242
243            return _get_type_schema(PlayerSource, definitions)
244
245    # Handle Union types (including Optional)
246    origin = get_origin(type_hint)
247    if origin is Union or origin is UnionType:
248        args = get_args(type_hint)
249        # Check if it's Optional (Union with None)
250        non_none_args = [arg for arg in args if arg not in (NoneType, type(None))]
251        if (len(non_none_args) == 1 and NoneType in args) or type(None) in args:
252            # It's Optional[T], make it nullable
253            schema = _get_type_schema(non_none_args[0], definitions)
254            schema["nullable"] = True
255            return schema
256        # It's a union of multiple types
257        return {"oneOf": [_get_type_schema(arg, definitions) for arg in args]}
258
259    # Handle UniqueList (treat as array)
260    if hasattr(type_hint, "__name__") and type_hint.__name__ == "UniqueList":
261        args = get_args(type_hint)
262        if args:
263            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
264        return {"type": "array", "items": {}}
265
266    # Handle Sequence types (from collections.abc or typing)
267    if origin is collections.abc.Sequence or (
268        hasattr(origin, "__name__") and origin.__name__ == "Sequence"
269    ):
270        args = get_args(type_hint)
271        if args:
272            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
273        return {"type": "array", "items": {}}
274
275    # Handle set/frozenset types
276    if origin in (set, frozenset):
277        args = get_args(type_hint)
278        if args:
279            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
280        return {"type": "array", "items": {}}
281
282    # Handle list/tuple types
283    if origin in (list, tuple):
284        args = get_args(type_hint)
285        if args:
286            return {"type": "array", "items": _get_type_schema(args[0], definitions)}
287        return {"type": "array", "items": {}}
288
289    # Handle dict types
290    if origin is dict:
291        args = get_args(type_hint)
292        if len(args) == 2:
293            return {
294                "type": "object",
295                "additionalProperties": _get_type_schema(args[1], definitions),
296            }
297        return {"type": "object", "additionalProperties": True}
298
299    # Handle Enum types - add them to definitions as explorable objects
300    if inspect.isclass(type_hint) and issubclass(type_hint, Enum):
301        enum_name = type_hint.__name__
302        if enum_name not in definitions:
303            enum_values = [item.value for item in type_hint]
304            enum_type = type(enum_values[0]).__name__ if enum_values else "string"
305            openapi_type = {
306                "str": "string",
307                "int": "integer",
308                "float": "number",
309                "bool": "boolean",
310            }.get(enum_type, "string")
311
312            # Create a detailed enum definition with descriptions
313            enum_values_str = ", ".join(str(v) for v in enum_values)
314            definitions[enum_name] = {
315                "type": openapi_type,
316                "enum": enum_values,
317                "description": f"Enum: {enum_name}. Possible values: {enum_values_str}",
318            }
319        return {"$ref": f"#/components/schemas/{enum_name}"}
320
321    # Handle datetime
322    if type_hint is datetime:
323        return {"type": "string", "format": "date-time"}
324
325    # Handle primitive types - check both exact type and type name
326    if type_hint is str or (hasattr(type_hint, "__name__") and type_hint.__name__ == "str"):
327        return {"type": "string"}
328    if type_hint is int or (hasattr(type_hint, "__name__") and type_hint.__name__ == "int"):
329        return {"type": "integer"}
330    if type_hint is float or (hasattr(type_hint, "__name__") and type_hint.__name__ == "float"):
331        return {"type": "number"}
332    if type_hint is bool or (hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"):
333        return {"type": "boolean"}
334
335    # Handle complex types (dataclasses, models)
336    # Check for __annotations__ or if it's a class (not already handled above)
337    if hasattr(type_hint, "__annotations__") or (
338        inspect.isclass(type_hint) and not issubclass(type_hint, (str, int, float, bool, Enum))
339    ):
340        type_name = getattr(type_hint, "__name__", str(type_hint))
341        # Add to definitions if not already there
342        if type_name not in definitions:
343            properties = {}
344            required = []
345
346            # Check if this is a dataclass with fields
347            if hasattr(type_hint, "__dataclass_fields__"):
348                # Resolve type hints to handle forward references from __future__ annotations
349                try:
350                    resolved_hints = get_type_hints(type_hint)
351                except Exception:
352                    resolved_hints = {}
353
354                # Use dataclass fields to get proper info including defaults and metadata
355                for field_name, field_info in type_hint.__dataclass_fields__.items():
356                    # Skip fields marked with serialize="omit" in metadata
357                    if field_info.metadata:
358                        # Check for mashumaro field_options
359                        if "serialize" in field_info.metadata:
360                            if field_info.metadata["serialize"] == "omit":
361                                continue
362
363                    # Use resolved type hint if available, otherwise fall back to field type
364                    field_type = resolved_hints.get(field_name, field_info.type)
365                    field_schema = _get_type_schema(field_type, definitions)
366
367                    # Add default value if present
368                    if field_info.default is not MISSING:
369                        field_schema["default"] = field_info.default
370                    elif (
371                        hasattr(field_info, "default_factory")
372                        and field_info.default_factory is not MISSING
373                    ):
374                        # Has a default factory - don't add anything, just skip
375                        pass
376
377                    properties[field_name] = field_schema
378
379                    # Check if field is required (not Optional and no default)
380                    has_default = field_info.default is not MISSING or (
381                        hasattr(field_info, "default_factory")
382                        and field_info.default_factory is not MISSING
383                    )
384                    is_optional = get_origin(field_type) in (
385                        Union,
386                        UnionType,
387                    ) and NoneType in get_args(field_type)
388                    if not has_default and not is_optional:
389                        required.append(field_name)
390            elif hasattr(type_hint, "__annotations__"):
391                # Fallback for non-dataclass types with annotations
392                for field_name, field_type in type_hint.__annotations__.items():
393                    properties[field_name] = _get_type_schema(field_type, definitions)
394                    # Check if field is required (not Optional)
395                    if not (
396                        get_origin(field_type) in (Union, UnionType)
397                        and NoneType in get_args(field_type)
398                    ):
399                        required.append(field_name)
400            else:
401                # Class without dataclass fields or annotations - treat as generic object
402                pass  # Will create empty properties
403
404            definitions[type_name] = {
405                "type": "object",
406                "properties": properties,
407            }
408            if required:
409                definitions[type_name]["required"] = required
410
411        return {"$ref": f"#/components/schemas/{type_name}"}
412
413    # Handle Any
414    if type_hint is Any:
415        return {"type": "object"}
416
417    # Fallback - for types we don't recognize, at least return a generic object type
418    return {"type": "object"}
419
420
421def _parse_docstring(  # noqa: PLR0915
422    func: Callable[..., Any],
423) -> tuple[str, str, dict[str, str]]:
424    """Parse docstring to extract summary, description and parameter descriptions.
425
426    Returns:
427        Tuple of (short_summary, full_description, param_descriptions)
428
429    Handles multiple docstring formats:
430    - reStructuredText (:param name: description)
431    - Google style (Args: section)
432    - NumPy style (Parameters section)
433    """
434    docstring = inspect.getdoc(func)
435    if not docstring:
436        return "", "", {}
437
438    lines = docstring.split("\n")
439    description_lines = []
440    param_descriptions = {}
441    current_section = "description"
442    current_param = None
443
444    for line in lines:
445        stripped = line.strip()
446
447        # Check for section headers
448        if stripped.lower() in ("args:", "arguments:", "parameters:", "params:"):
449            current_section = "params"
450            current_param = None
451            continue
452        if stripped.lower() in (
453            "returns:",
454            "return:",
455            "yields:",
456            "raises:",
457            "raises",
458            "examples:",
459            "example:",
460            "note:",
461            "notes:",
462            "see also:",
463            "warning:",
464            "warnings:",
465        ):
466            current_section = "other"
467            current_param = None
468            continue
469
470        # Parse :param style
471        if stripped.startswith(":param "):
472            current_section = "params"
473            parts = stripped[7:].split(":", 1)
474            if len(parts) == 2:
475                current_param = parts[0].strip()
476                desc = parts[1].strip()
477                if desc:
478                    param_descriptions[current_param] = desc
479            continue
480
481        if stripped.startswith((":type ", ":rtype", ":return")):
482            current_section = "other"
483            current_param = None
484            continue
485
486        # Detect bullet-style params even without explicit section header
487        # Format: "- param_name: description"
488        if stripped.startswith("- ") and ":" in stripped:
489            # This is likely a bullet-style parameter
490            current_section = "params"
491            content = stripped[2:]  # Remove "- "
492            parts = content.split(":", 1)
493            param_name = parts[0].strip()
494            desc_part = parts[1].strip() if len(parts) > 1 else ""
495            if param_name and not param_name.startswith(("return", "yield", "raise")):
496                current_param = param_name
497                if desc_part:
498                    param_descriptions[current_param] = desc_part
499            continue
500
501        # In params section, detect param lines (indented or starting with name)
502        if current_section == "params" and stripped:
503            # Google/NumPy style: "param_name: description" or "param_name (type): description"
504            if ":" in stripped and not stripped.startswith(" "):
505                # Likely a parameter definition
506                if "(" in stripped and ")" in stripped:
507                    # Format: param_name (type): description
508                    param_part = stripped.split(":")[0]
509                    param_name = param_part.split("(")[0].strip()
510                    desc_part = ":".join(stripped.split(":")[1:]).strip()
511                else:
512                    # Format: param_name: description
513                    parts = stripped.split(":", 1)
514                    param_name = parts[0].strip()
515                    desc_part = parts[1].strip() if len(parts) > 1 else ""
516
517                if param_name and not param_name.startswith(("return", "yield", "raise")):
518                    current_param = param_name
519                    if desc_part:
520                        param_descriptions[current_param] = desc_part
521            elif current_param and stripped:
522                # Continuation of previous parameter description
523                param_descriptions[current_param] = (
524                    param_descriptions.get(current_param, "") + " " + stripped
525                ).strip()
526            continue
527
528        # Collect description lines (only before params/returns sections)
529        if current_section == "description" and stripped:
530            description_lines.append(stripped)
531        elif current_section == "description" and not stripped and description_lines:
532            # Empty line in description - keep it for paragraph breaks
533            description_lines.append("")
534
535    # Join description lines, removing excessive empty lines
536    description = "\n".join(description_lines).strip()
537    # Collapse multiple empty lines into one
538    while "\n\n\n" in description:
539        description = description.replace("\n\n\n", "\n\n")
540
541    # Extract first sentence/line as summary
542    summary = ""
543    if description:
544        # Get first line or first sentence (whichever is shorter)
545        first_line = description.split("\n")[0]
546        # Try to get first sentence (ending with .)
547        summary = first_line.split(".")[0] + "." if "." in first_line else first_line
548
549    return summary, description, param_descriptions
550
551
552def generate_openapi_spec(
553    command_handlers: dict[str, APICommandHandler],
554    server_url: str = "http://localhost:8095",
555    version: str = "1.0.0",
556) -> dict[str, Any]:
557    """Generate simplified OpenAPI 3.0 specification focusing on data models.
558
559    This spec documents the single /api endpoint and all data models/schemas.
560    For detailed command documentation, see the Commands Reference page.
561    """
562    definitions: dict[str, Any] = {}
563
564    # Build all schemas from command handlers (this populates definitions)
565    for handler in command_handlers.values():
566        # Skip aliases - they are for backward compatibility only
567        if handler.alias:
568            continue
569        # Build parameter schemas
570        for param_name in handler.signature.parameters:
571            if param_name == "self":
572                continue
573            # Skip return_type parameter (used only for type hints)
574            if param_name == "return_type":
575                continue
576            param_type = handler.type_hints.get(param_name, Any)
577            # Skip Any types as they don't provide useful schema information
578            if param_type is not Any and str(param_type) != "typing.Any":
579                _get_type_schema(param_type, definitions)
580
581        # Build return type schema
582        return_type = handler.type_hints.get("return", Any)
583        # Skip Any types as they don't provide useful schema information
584        if return_type is not Any and str(return_type) != "typing.Any":
585            _get_type_schema(return_type, definitions)
586
587    # Build a single /api endpoint with generic request/response
588    paths = {
589        "/api": {
590            "post": {
591                "summary": "Execute API command",
592                "description": (
593                    "Execute any Music Assistant API command.\n\n"
594                    "See the **Commands Reference** page for a complete list of available "
595                    "commands with examples."
596                ),
597                "operationId": "execute_command",
598                "security": [{"bearerAuth": []}],
599                "requestBody": {
600                    "required": True,
601                    "content": {
602                        "application/json": {
603                            "schema": {
604                                "type": "object",
605                                "required": ["command"],
606                                "properties": {
607                                    "command": {
608                                        "type": "string",
609                                        "description": (
610                                            "The command to execute (e.g., 'players/all')"
611                                        ),
612                                        "example": "players/all",
613                                    },
614                                    "args": {
615                                        "type": "object",
616                                        "description": "Command arguments (varies by command)",
617                                        "additionalProperties": True,
618                                        "example": {},
619                                    },
620                                },
621                            },
622                            "examples": {
623                                "get_players": {
624                                    "summary": "Get all players",
625                                    "value": {"command": "players/all", "args": {}},
626                                },
627                                "play_media": {
628                                    "summary": "Play media on a player",
629                                    "value": {
630                                        "command": "players/cmd/play",
631                                        "args": {"player_id": "player123"},
632                                    },
633                                },
634                            },
635                        }
636                    },
637                },
638                "responses": {
639                    "200": {
640                        "description": "Successful command execution",
641                        "content": {
642                            "application/json": {
643                                "schema": {"description": "Command result (varies by command)"}
644                            }
645                        },
646                    },
647                    "400": {"description": "Bad request - invalid command or parameters"},
648                    "401": {"description": "Unauthorized - authentication required"},
649                    "403": {"description": "Forbidden - insufficient permissions"},
650                    "500": {"description": "Internal server error"},
651                },
652            }
653        },
654        "/auth/login": {
655            "post": {
656                "summary": "Authenticate with credentials",
657                "description": "Login with username and password to obtain an access token.",
658                "operationId": "auth_login",
659                "tags": ["Authentication"],
660                "requestBody": {
661                    "required": True,
662                    "content": {
663                        "application/json": {
664                            "schema": {
665                                "type": "object",
666                                "properties": {
667                                    "provider_id": {
668                                        "type": "string",
669                                        "description": "Auth provider ID (defaults to 'builtin')",
670                                        "example": "builtin",
671                                    },
672                                    "credentials": {
673                                        "type": "object",
674                                        "description": "Provider-specific credentials",
675                                        "properties": {
676                                            "username": {"type": "string"},
677                                            "password": {"type": "string"},
678                                        },
679                                    },
680                                },
681                            }
682                        }
683                    },
684                },
685                "responses": {
686                    "200": {
687                        "description": "Login successful",
688                        "content": {
689                            "application/json": {
690                                "schema": {
691                                    "type": "object",
692                                    "properties": {
693                                        "success": {"type": "boolean"},
694                                        "token": {"type": "string"},
695                                        "user": {"type": "object"},
696                                    },
697                                }
698                            }
699                        },
700                    },
701                    "400": {"description": "Invalid credentials"},
702                },
703            }
704        },
705        "/auth/providers": {
706            "get": {
707                "summary": "Get available auth providers",
708                "description": "Returns list of configured authentication providers.",
709                "operationId": "auth_providers",
710                "tags": ["Authentication"],
711                "responses": {
712                    "200": {
713                        "description": "List of auth providers",
714                        "content": {
715                            "application/json": {
716                                "schema": {
717                                    "type": "object",
718                                    "properties": {
719                                        "providers": {
720                                            "type": "array",
721                                            "items": {"type": "object"},
722                                        }
723                                    },
724                                }
725                            }
726                        },
727                    }
728                },
729            }
730        },
731        "/setup": {
732            "post": {
733                "summary": "Initial server setup",
734                "description": (
735                    "Handle initial setup of the Music Assistant server including creating "
736                    "the first admin user. Only accessible when no users exist."
737                ),
738                "operationId": "setup",
739                "tags": ["Server"],
740                "requestBody": {
741                    "required": True,
742                    "content": {
743                        "application/json": {
744                            "schema": {
745                                "type": "object",
746                                "required": ["username", "password"],
747                                "properties": {
748                                    "username": {"type": "string"},
749                                    "password": {"type": "string"},
750                                    "display_name": {"type": "string"},
751                                },
752                            }
753                        }
754                    },
755                },
756                "responses": {
757                    "200": {
758                        "description": "Setup completed successfully",
759                        "content": {
760                            "application/json": {
761                                "schema": {
762                                    "type": "object",
763                                    "properties": {
764                                        "success": {"type": "boolean"},
765                                        "token": {"type": "string"},
766                                        "user": {"type": "object"},
767                                    },
768                                }
769                            }
770                        },
771                    },
772                    "400": {"description": "Setup already completed or invalid request"},
773                },
774            }
775        },
776        "/info": {
777            "get": {
778                "summary": "Get server info",
779                "description": (
780                    "Returns server information including schema version and authentication status."
781                ),
782                "operationId": "get_info",
783                "tags": ["Server"],
784                "responses": {
785                    "200": {
786                        "description": "Server information",
787                        "content": {
788                            "application/json": {
789                                "schema": {
790                                    "type": "object",
791                                    "properties": {
792                                        "schema_version": {"type": "integer"},
793                                        "server_version": {"type": "string"},
794                                        "onboard_done": {"type": "boolean"},
795                                        "homeassistant_addon": {"type": "boolean"},
796                                    },
797                                }
798                            }
799                        },
800                    }
801                },
802            }
803        },
804    }
805
806    # Build OpenAPI spec
807    return {
808        "openapi": "3.0.0",
809        "info": {
810            "title": "Music Assistant API",
811            "version": version,
812            "description": (
813                "Music Assistant API provides control over your music library, "
814                "players, and playback.\n\n"
815                "This specification documents the API structure and data models. "
816                "For a complete list of available commands with examples, "
817                "see the Commands Reference page."
818            ),
819            "contact": {
820                "name": "Music Assistant",
821                "url": "https://music-assistant.io",
822            },
823        },
824        "servers": [{"url": server_url, "description": "Music Assistant Server"}],
825        "paths": paths,
826        "components": {
827            "schemas": definitions,
828            "securitySchemes": {
829                "bearerAuth": {
830                    "type": "http",
831                    "scheme": "bearer",
832                    "description": "Access token obtained from /auth/login or /auth/setup",
833                }
834            },
835        },
836    }
837
838
839def _split_union_type(type_str: str) -> list[str]:
840    """Split a union type on | but respect brackets and parentheses.
841
842    This ensures that list[A | B] and (A | B) are not split at the inner |.
843    """
844    parts = []
845    current_part = ""
846    bracket_depth = 0
847    paren_depth = 0
848    i = 0
849    while i < len(type_str):
850        char = type_str[i]
851        if char == "[":
852            bracket_depth += 1
853            current_part += char
854        elif char == "]":
855            bracket_depth -= 1
856            current_part += char
857        elif char == "(":
858            paren_depth += 1
859            current_part += char
860        elif char == ")":
861            paren_depth -= 1
862            current_part += char
863        elif char == "|" and bracket_depth == 0 and paren_depth == 0:
864            # Check if this is a union separator (has space before and after)
865            if (
866                i > 0
867                and i < len(type_str) - 1
868                and type_str[i - 1] == " "
869                and type_str[i + 1] == " "
870            ):
871                parts.append(current_part.strip())
872                current_part = ""
873                i += 1  # Skip the space after |, the loop will handle incrementing i
874            else:
875                current_part += char
876        else:
877            current_part += char
878        i += 1
879    if current_part.strip():
880        parts.append(current_part.strip())
881    return parts
882
883
884def _extract_generic_inner_type(type_str: str) -> str | None:
885    """Extract inner type from generic type like list[T] or dict[K, V].
886
887    :param type_str: Type string like "list[str]" or "dict[str, int]"
888    :return: Inner type string "str" or "str, int", or None if not a complete generic type
889    """
890    # Find the matching closing bracket
891    bracket_count = 0
892    start_idx = type_str.index("[") + 1
893    end_idx = -1
894    for i in range(start_idx, len(type_str)):
895        if type_str[i] == "[":
896            bracket_count += 1
897        elif type_str[i] == "]":
898            if bracket_count == 0:
899                end_idx = i
900                break
901            bracket_count -= 1
902
903    # Check if this is a complete generic type (ends with the closing bracket)
904    if end_idx == len(type_str) - 1:
905        return type_str[start_idx:end_idx].strip()
906    return None
907
908
909def _parse_dict_type_params(inner_type: str) -> tuple[str, str] | None:
910    """Parse key and value types from dict inner type string.
911
912    :param inner_type: The content inside dict[...], e.g., "str, ConfigValueType"
913    :return: Tuple of (key_type, value_type) or None if parsing fails
914    """
915    # Split on comma to get key and value types
916    # Need to be careful with nested types like dict[str, list[int]]
917    parts = []
918    current_part = ""
919    bracket_depth = 0
920    for char in inner_type:
921        if char == "[":
922            bracket_depth += 1
923            current_part += char
924        elif char == "]":
925            bracket_depth -= 1
926            current_part += char
927        elif char == "," and bracket_depth == 0:
928            parts.append(current_part.strip())
929            current_part = ""
930        else:
931            current_part += char
932    if current_part:
933        parts.append(current_part.strip())
934
935    if len(parts) == 2:
936        return parts[0], parts[1]
937    return None
938
939
940def _python_type_to_json_type(type_str: str, _depth: int = 0) -> str:
941    """Convert Python type string to JSON/JavaScript type string.
942
943    Args:
944        type_str: The type string to convert
945        _depth: Internal recursion depth tracker (do not set manually)
946    """
947    # Prevent infinite recursion
948    if _depth > 50:
949        return "any"
950
951    # Remove typing module prefix and class markers
952    type_str = type_str.replace("typing.", "").replace("<class '", "").replace("'>", "")
953
954    # Remove module paths from type names (e.g., "music_assistant.models.Artist" -> "Artist")
955    type_str = re.sub(r"[\w.]+\.(\w+)", r"\1", type_str)
956
957    # Check for type aliases that should be preserved as-is
958    # These will have schema definitions in the API docs
959    if type_str in ("ConfigValueType", "MediaItemType"):
960        return type_str
961
962    # Map Python types to JSON types
963    type_mappings = {
964        "str": "string",
965        "int": "integer",
966        "float": "number",
967        "bool": "boolean",
968        "dict": "object",
969        "Dict": "object",
970        "list": "array",
971        "tuple": "array",
972        "Tuple": "array",
973        "None": "null",
974        "NoneType": "null",
975    }
976
977    # Check for List/list/UniqueList/tuple with type parameter BEFORE checking for union types
978    # This is important because list[A | B] contains " | " but should be handled as a list first
979    # codespell:ignore
980    if type_str.startswith(("list[", "List[", "UniqueList[", "tuple[", "Tuple[")):
981        inner_type = _extract_generic_inner_type(type_str)
982        if inner_type:
983            # Handle variable-length tuple (e.g., tuple[str, ...])
984            # The ellipsis means "variable length of this type"
985            if inner_type.endswith(", ..."):
986                # Remove the ellipsis and just use the type
987                inner_type = inner_type[:-5].strip()
988            # Recursively convert the inner type
989            inner_json_type = _python_type_to_json_type(inner_type, _depth + 1)
990            # For list[A | B], wrap in parentheses to keep it as one unit
991            # This prevents "Array of A | B" from being split into separate union parts
992            if " | " in inner_json_type:
993                return f"Array of ({inner_json_type})"
994            return f"Array of {inner_json_type}"
995
996    # Check for dict/Dict with type parameters BEFORE checking for union types
997    # This is important because dict[str, A | B] contains " | "
998    # but should be handled as a dict first
999    # codespell:ignore
1000    if type_str.startswith(("dict[", "Dict[")):
1001        inner_type = _extract_generic_inner_type(type_str)
1002        if inner_type:
1003            parsed = _parse_dict_type_params(inner_type)
1004            if parsed:
1005                key_type_str, value_type_str = parsed
1006                key_type = _python_type_to_json_type(key_type_str, _depth + 1)
1007                value_type = _python_type_to_json_type(value_type_str, _depth + 1)
1008                # Use more descriptive format: "object with {key_type} keys and {value_type} values"
1009                return f"object with {key_type} keys and {value_type} values"
1010
1011    # Handle Union types by splitting on | and recursively processing each part
1012    if " | " in type_str:
1013        # Use helper to split on | but respect brackets
1014        parts = _split_union_type(type_str)
1015
1016        # Filter out None/null types (None, NoneType, null all mean JSON null)
1017        parts = [part for part in parts if part not in ("None", "NoneType", "null")]
1018
1019        # If splitting didn't help (only one part or same as input), avoid infinite recursion
1020        if not parts or (len(parts) == 1 and parts[0] == type_str):
1021            # Can't split further, return as-is or "any"
1022            return type_str if parts else "any"
1023
1024        if parts:
1025            converted_parts = [_python_type_to_json_type(part, _depth + 1) for part in parts]
1026            # Remove duplicates while preserving order
1027            seen = set()
1028            unique_parts = []
1029            for part in converted_parts:
1030                if part not in seen:
1031                    seen.add(part)
1032                    unique_parts.append(part)
1033            return " | ".join(unique_parts)
1034        return "any"
1035
1036    # Check for Union/Optional types with brackets
1037    if "Union[" in type_str or "Optional[" in type_str:
1038        # Extract content from Union[...] or Optional[...]
1039        union_match = re.search(r"(?:Union|Optional)\[([^\]]+)\]", type_str)
1040        if union_match:
1041            inner = union_match.group(1)
1042            # Recursively process the union content
1043            return _python_type_to_json_type(inner, _depth + 1)
1044
1045    # Direct mapping for basic types
1046    for py_type, json_type in type_mappings.items():
1047        if type_str == py_type:
1048            return json_type
1049
1050    # Check if it's a complex type (starts with capital letter)
1051    complex_match = re.search(r"^([A-Z][a-zA-Z0-9_]*)$", type_str)
1052    if complex_match:
1053        return complex_match.group(1)
1054
1055    # Default to the original string if no mapping found
1056    return type_str
1057
1058
1059def _make_type_links(type_str: str, server_url: str, as_list: bool = False) -> str:
1060    """Convert type string to HTML with links to schemas reference for complex types.
1061
1062    Args:
1063        type_str: The type string to convert
1064        server_url: Base server URL for building links
1065        as_list: If True and type contains |, format as "Any of:" bullet list
1066    """
1067
1068    # Find all complex types (capitalized words that aren't basic types)
1069    def replace_type(match: re.Match[str]) -> str:
1070        type_name = match.group(0)
1071        # Check if it's a complex type (starts with capital letter)
1072        # Exclude basic types and "Array" (which is used in "Array of Type")
1073        excluded = {"Union", "Optional", "List", "Dict", "Array", "None", "NoneType"}
1074        if type_name[0].isupper() and type_name not in excluded:
1075            # Create link to our schemas reference page
1076            schema_url = f"{server_url}/api-docs/schemas#schema-{type_name}"
1077            return f'<a href="{schema_url}" class="type-link">{type_name}</a>'
1078        return type_name
1079
1080    # If it's a union type with multiple options and as_list is True, format as bullet list
1081    if as_list and " | " in type_str:
1082        # Use the bracket/parenthesis-aware splitter
1083        parts = _split_union_type(type_str)
1084        # Only use list format if there are 3+ options
1085        if len(parts) >= 3:
1086            html = '<div class="type-union"><span class="type-union-label">Any of:</span><ul>'
1087            for part in parts:
1088                linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part)
1089                html += f"<li>{linked_part}</li>"
1090            html += "</ul></div>"
1091            return html
1092
1093    # Replace complex type names with links
1094    result: str = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, type_str)
1095    return result
1096
1097
1098def generate_commands_json(command_handlers: dict[str, APICommandHandler]) -> list[dict[str, Any]]:
1099    """Generate JSON representation of all available API commands.
1100
1101    This is used by client libraries to sync their methods with the server API.
1102
1103    Returns a list of command objects with the following structure:
1104    {
1105        "command": str,  # Command name (e.g., "music/tracks/library_items")
1106        "category": str,  # Category (e.g., "Music")
1107        "summary": str,  # Short description
1108        "description": str,  # Full description
1109        "parameters": [  # List of parameters
1110            {
1111                "name": str,
1112                "type": str,  # JSON type (string, integer, boolean, etc.)
1113                "required": bool,
1114                "description": str
1115            }
1116        ],
1117        "return_type": str,  # Return type
1118        "authenticated": bool,  # Whether authentication is required
1119        "required_role": str | None,  # Required user role (if any)
1120    }
1121    """
1122    commands_data = []
1123
1124    for command, handler in sorted(command_handlers.items()):
1125        # Skip aliases - they are for backward compatibility only
1126        if handler.alias:
1127            continue
1128        # Parse docstring
1129        summary, description, param_descriptions = _parse_docstring(handler.target)
1130
1131        # Get return type
1132        return_type = handler.type_hints.get("return", Any)
1133        # If type is already a string (e.g., "ConfigValueType"), use it directly
1134        return_type_str = _python_type_to_json_type(
1135            return_type if isinstance(return_type, str) else str(return_type)
1136        )
1137
1138        # Extract category from command name
1139        category = command.split("/")[0] if "/" in command else "general"
1140        category_display = category.replace("_", " ").title()
1141
1142        # Build parameters list
1143        parameters = []
1144        for param_name, param in handler.signature.parameters.items():
1145            if param_name in ("self", "return_type"):
1146                continue
1147
1148            is_required = param.default is inspect.Parameter.empty
1149            param_type = handler.type_hints.get(param_name, Any)
1150            # If type is already a string (e.g., "ConfigValueType"), use it directly
1151            type_str = param_type if isinstance(param_type, str) else str(param_type)
1152            json_type_str = _python_type_to_json_type(type_str)
1153            param_desc = param_descriptions.get(param_name, "")
1154
1155            parameters.append(
1156                {
1157                    "name": param_name,
1158                    "type": json_type_str,
1159                    "required": is_required,
1160                    "description": param_desc,
1161                }
1162            )
1163
1164        commands_data.append(
1165            {
1166                "command": command,
1167                "category": category_display,
1168                "summary": summary or "",
1169                "description": description or "",
1170                "parameters": parameters,
1171                "return_type": return_type_str,
1172                "authenticated": handler.authenticated,
1173                "required_role": handler.required_role,
1174            }
1175        )
1176
1177    return commands_data
1178
1179
1180def generate_schemas_json(command_handlers: dict[str, APICommandHandler]) -> dict[str, Any]:
1181    """Generate JSON representation of all schemas/data models.
1182
1183    Returns a dict mapping schema names to their OpenAPI schema definitions.
1184    """
1185    schemas: dict[str, Any] = {}
1186
1187    for handler in command_handlers.values():
1188        # Skip aliases - they are for backward compatibility only
1189        if handler.alias:
1190            continue
1191        # Collect schemas from parameters
1192        for param_name in handler.signature.parameters:
1193            if param_name == "self":
1194                continue
1195            # Skip return_type parameter (used only for type hints)
1196            if param_name == "return_type":
1197                continue
1198            param_type = handler.type_hints.get(param_name, Any)
1199            if param_type is not Any and str(param_type) != "typing.Any":
1200                _get_type_schema(param_type, schemas)
1201
1202        # Collect schemas from return type
1203        return_type = handler.type_hints.get("return", Any)
1204        if return_type is not Any and str(return_type) != "typing.Any":
1205            _get_type_schema(return_type, schemas)
1206
1207    return schemas
1208