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