/
/
/
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