APPLICATION_NAME: Final = "Music Assistant"
-API_SCHEMA_VERSION: Final[int] = 27
-MIN_SCHEMA_VERSION: Final[int] = 24
+API_SCHEMA_VERSION: Final[int] = 28
+MIN_SCHEMA_VERSION: Final[int] = 28
MASS_LOGGER_NAME: Final[str] = "music_assistant"
+# Home Assistant system user
+HOMEASSISTANT_SYSTEM_USER: Final[str] = "homeassistant_system"
+
UNKNOWN_ARTIST: Final[str] = "[unknown]"
UNKNOWN_ARTIST_ID_MBID: Final[str] = "125ec42a-7229-4250-afc5-e057484327fe"
VARIOUS_ARTISTS_NAME: Final[str] = "Various Artists"
CONF_USE_SSL: Final[str] = "use_ssl"
CONF_VERIFY_SSL: Final[str] = "verify_ssl"
CONF_SSL_FINGERPRINT: Final[str] = "ssl_fingerprint"
+CONF_AUTH_ALLOW_SELF_REGISTRATION: Final[str] = "auth_allow_self_registration"
# config default values
),
]
- @api_command("config/providers/save")
+ @api_command("config/providers/save", required_role="admin")
async def save_provider_config(
self,
provider_domain: str,
# return full config, just in case
return await self.get_provider_config(config.instance_id)
- @api_command("config/providers/remove")
+ @api_command("config/providers/remove", required_role="admin")
async def remove_provider_config(self, instance_id: str) -> None:
"""Remove ProviderConfig."""
conf_key = f"{CONF_PROVIDERS}/{instance_id}"
}
return cast("PlayerConfig", PlayerConfig.parse([], raw_conf))
- @api_command("config/players/save")
+ @api_command("config/players/save", required_role="admin")
async def save_player_config(
self, player_id: str, values: dict[str, ConfigValueType]
) -> PlayerConfig:
# return full player config (just in case)
return await self.get_player_config(player_id)
- @api_command("config/players/remove")
+ @api_command("config/players/remove", required_role="admin")
async def remove_player_config(self, player_id: str) -> None:
"""Remove PlayerConfig."""
conf_key = f"{CONF_PLAYERS}/{player_id}"
return dsp_config
- @api_command("config/players/dsp/save")
+ @api_command("config/players/dsp/save", required_role="admin")
async def save_dsp_config(self, player_id: str, config: DSPConfig) -> DSPConfig:
"""
Save/update DSPConfig for a player.
raw_presets = self.get(CONF_PLAYER_DSP_PRESETS, {})
return [DSPConfigPreset.from_dict(preset) for preset in raw_presets.values()]
- @api_command("config/dsp_presets/save")
+ @api_command("config/dsp_presets/save", required_role="admin")
async def save_dsp_presets(self, preset: DSPConfigPreset) -> DSPConfigPreset:
"""
Save/update a user-defined DSP presets.
return preset
- @api_command("config/dsp_presets/remove")
+ @api_command("config/dsp_presets/remove", required_role="admin")
async def remove_dsp_preset(self, preset_id: str) -> None:
"""Remove a user-defined DSP preset."""
self.mass.config.remove(f"{CONF_PLAYER_DSP_PRESETS}/preset_{preset_id}")
conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
self.set(conf_key, default_config.to_raw())
- @api_command("config/core")
+ @api_command("config/core", required_role="admin")
async def get_core_configs(self, include_values: bool = False) -> list[CoreConfig]:
"""Return all core controllers config options."""
return [
domain: str,
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
- ) -> tuple[ConfigEntry, ...]:
+ ) -> list[ConfigEntry]:
"""
Return Config entries to configure a core controller.
if values is None:
values = self.get(f"{CONF_CORE}/{domain}/values", {})
controller: CoreController = getattr(self.mass, domain)
- return (
+ return list(
await controller.get_config_entries(action=action, values=values)
+ DEFAULT_CORE_CONFIG_ENTRIES
)
- @api_command("config/core/save")
+ @api_command("config/core/save", required_role="admin")
async def save_core_config(
self,
domain: str,
]
changed = True
- # set 'onboard_done' flag if we have any (non default) provider configs
- if self._data.get(CONF_ONBOARD_DONE) is None:
- default_providers = {x.domain for x in self.mass.get_provider_manifests() if x.builtin}
- for provider_config in self._data.get(CONF_PROVIDERS, {}).values():
- if provider_config["domain"] not in default_providers:
- self._data[CONF_ONBOARD_DONE] = True
- changed = True
- break
-
# migrate player_group entries
ugp_found = False
for player_config in self._data.get(CONF_PLAYERS, {}).values():
await _file.write(await async_json_dumps(self._data, indent=True))
LOGGER.debug("Saved data to persistent storage")
- @api_command("config/providers/reload")
+ @api_command("config/providers/reload", required_role="admin")
async def _reload_provider(self, instance_id: str) -> None:
"""Reload provider."""
try:
from typing import TYPE_CHECKING, Any, TypeVar, cast
from music_assistant_models.enums import EventType, ExternalID, MediaType, ProviderFeature
-from music_assistant_models.errors import (
- MediaNotFoundError,
- ProviderUnavailableError,
-)
+from music_assistant_models.errors import MediaNotFoundError, ProviderUnavailableError
from music_assistant_models.media_items import ItemMapping, MediaItemType, ProviderMapping, Track
from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PROVIDER_MAPPINGS, MASS_LOGGER_NAME
self.mass.register_api_command(f"music/{api_base}/count", self.library_count)
self.mass.register_api_command(f"music/{api_base}/library_items", self.library_items)
self.mass.register_api_command(f"music/{api_base}/get", self.get)
- self.mass.register_api_command(f"music/{api_base}/get_{self.media_type}", self.get)
- self.mass.register_api_command(f"music/{api_base}/add", self.add_item_to_library)
- self.mass.register_api_command(f"music/{api_base}/update", self.update_item_in_library)
- self.mass.register_api_command(f"music/{api_base}/remove", self.remove_item_from_library)
+ # Backward compatibility alias - prefer the generic "get" endpoint
+ self.mass.register_api_command(
+ f"music/{api_base}/get_{self.media_type}", self.get, alias=True
+ )
+ self.mass.register_api_command(
+ f"music/{api_base}/update", self.update_item_in_library, required_role="admin"
+ )
+ self.mass.register_api_command(
+ f"music/{api_base}/remove", self.remove_item_from_library, required_role="admin"
+ )
self._db_add_lock = asyncio.Lock()
async def add_item_to_library(
+++ /dev/null
-"""
-Controller that manages the builtin webserver that hosts the api and frontend.
-
-Unlike the streamserver (which is as simple and unprotected as possible),
-this webserver allows for more fine grained configuration to better secure it.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import html
-import logging
-import os
-import urllib.parse
-from collections.abc import Awaitable, Callable
-from concurrent import futures
-from contextlib import suppress
-from functools import partial
-from typing import TYPE_CHECKING, Any, Final, cast
-
-import aiofiles
-from aiohttp import WSMsgType, web
-from mashumaro.exceptions import MissingField
-from music_assistant_frontend import where as locate_frontend
-from music_assistant_models.api import (
- CommandMessage,
- ErrorResultMessage,
- MessageType,
- SuccessResultMessage,
-)
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
-from music_assistant_models.enums import ConfigEntryType
-from music_assistant_models.errors import InvalidCommand
-
-from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT, VERBOSE_LOG_LEVEL
-from music_assistant.helpers.api import APICommandHandler, parse_arguments
-from music_assistant.helpers.api_docs import (
- generate_commands_reference,
- generate_openapi_spec,
- generate_schemas_reference,
-)
-from music_assistant.helpers.audio import get_preview_stream
-from music_assistant.helpers.json import json_dumps, json_loads
-from music_assistant.helpers.util import get_ip_addresses
-from music_assistant.helpers.webserver import Webserver
-from music_assistant.models.core_controller import CoreController
-
-if TYPE_CHECKING:
- from music_assistant_models.config_entries import ConfigValueType, CoreConfig
- from music_assistant_models.event import MassEvent
-
- from music_assistant import MusicAssistant
-
-DEFAULT_SERVER_PORT = 8095
-INGRESS_SERVER_PORT = 8094
-CONF_BASE_URL = "base_url"
-MAX_PENDING_MSG = 512
-CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
-
-
-class WebserverController(CoreController):
- """Core Controller that manages the builtin webserver that hosts the api and frontend."""
-
- domain: str = "webserver"
-
- def __init__(self, mass: MusicAssistant) -> None:
- """Initialize instance."""
- super().__init__(mass)
- self._server = Webserver(self.logger, enable_dynamic_routes=True)
- self.register_dynamic_route = self._server.register_dynamic_route
- self.unregister_dynamic_route = self._server.unregister_dynamic_route
- self.clients: set[WebsocketClientHandler] = set()
- self.manifest.name = "Web Server (frontend and api)"
- self.manifest.description = (
- "The built-in webserver that hosts the Music Assistant Websockets API and frontend"
- )
- self.manifest.icon = "web-box"
-
- @property
- def base_url(self) -> str:
- """Return the base_url for the streamserver."""
- return self._server.base_url
-
- async def get_config_entries(
- self,
- action: str | None = None,
- values: dict[str, ConfigValueType] | None = None,
- ) -> tuple[ConfigEntry, ...]:
- """Return all Config Entries for this core module (if any)."""
- ip_addresses = await get_ip_addresses()
- default_publish_ip = ip_addresses[0]
- default_base_url = f"http://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
- return (
- ConfigEntry(
- key="webserver_warn",
- type=ConfigEntryType.ALERT,
- label="Please note that the webserver is unprotected. "
- "Never ever expose the webserver directly to the internet! \n\n"
- "Use a reverse proxy or VPN to secure access.",
- required=False,
- ),
- ConfigEntry(
- key=CONF_BASE_URL,
- type=ConfigEntryType.STRING,
- default_value=default_base_url,
- label="Base URL",
- description="The (base) URL to reach this webserver in the network. \n"
- "Override this in advanced scenarios where for example you're running "
- "the webserver behind a reverse proxy.",
- ),
- ConfigEntry(
- key=CONF_BIND_PORT,
- type=ConfigEntryType.INTEGER,
- default_value=DEFAULT_SERVER_PORT,
- label="TCP Port",
- description="The TCP port to run the webserver.",
- ),
- ConfigEntry(
- key=CONF_BIND_IP,
- type=ConfigEntryType.STRING,
- default_value="0.0.0.0",
- options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
- label="Bind to IP/interface",
- description="Bind the (web)server to this specific interface. \n"
- "Use 0.0.0.0 to bind to all interfaces. \n"
- "Set this address for example to a docker-internal network, "
- "when you are running a reverse proxy to enhance security and "
- "protect outside access to the webinterface and API. \n\n"
- "This is an advanced setting that should normally "
- "not be adjusted in regular setups.",
- category="advanced",
- ),
- )
-
- async def setup(self, config: CoreConfig) -> None:
- """Async initialize of module."""
- # work out all routes
- routes: list[tuple[str, str, Callable[[web.Request], Awaitable[web.StreamResponse]]]] = []
- # frontend routes
- frontend_dir = locate_frontend()
- for filename in next(os.walk(frontend_dir))[2]:
- if filename.endswith(".py"):
- continue
- filepath = os.path.join(frontend_dir, filename)
- handler = partial(self._server.serve_static, filepath)
- routes.append(("GET", f"/{filename}", handler))
- # add index
- index_path = os.path.join(frontend_dir, "index.html")
- handler = partial(self._server.serve_static, index_path)
- routes.append(("GET", "/", handler))
- # add info
- routes.append(("GET", "/info", self._handle_server_info))
- # add logging
- routes.append(("GET", "/music-assistant.log", self._handle_application_log))
- # add websocket api
- routes.append(("GET", "/ws", self._handle_ws_client))
- # also host the image proxy on the webserver
- routes.append(("GET", "/imageproxy", self.mass.metadata.handle_imageproxy))
- # also host the audio preview service
- routes.append(("GET", "/preview", self.serve_preview_stream))
- # add jsonrpc api
- routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
- # add api documentation
- routes.append(("GET", "/api-docs", self._handle_api_intro))
- routes.append(("GET", "/api-docs/", self._handle_api_intro))
- routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
- routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
- routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
- routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
- routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
- routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
- routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
- # start the webserver
- all_ip_addresses = await get_ip_addresses()
- default_publish_ip = all_ip_addresses[0]
- if self.mass.running_as_hass_addon:
- # if we're running on the HA supervisor we start an additional TCP site
- # on the internal ("172.30.32.) IP for the HA ingress proxy
- ingress_host = next(
- (x for x in all_ip_addresses if x.startswith("172.30.32.")), default_publish_ip
- )
- ingress_tcp_site_params = (ingress_host, INGRESS_SERVER_PORT)
- else:
- ingress_tcp_site_params = None
- base_url = str(config.get_value(CONF_BASE_URL))
- port_value = config.get_value(CONF_BIND_PORT)
- assert isinstance(port_value, int)
- self.publish_port = port_value
- self.publish_ip = default_publish_ip
- bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
- # print a big fat message in the log where the webserver is running
- # because this is a common source of issues for people with more complex setups
- if not self.mass.config.onboard_done:
- self.logger.warning(
- "\n\n################################################################################\n"
- "Starting webserver on %s:%s - base url: %s\n"
- "If this is incorrect, see the documentation how to configure the Webserver\n"
- "in Settings --> Core modules --> Webserver\n"
- "################################################################################\n",
- bind_ip,
- self.publish_port,
- base_url,
- )
- else:
- self.logger.info(
- "Starting webserver on %s:%s - base url: %s\n#\n",
- bind_ip,
- self.publish_port,
- base_url,
- )
- await self._server.setup(
- bind_ip=bind_ip,
- bind_port=self.publish_port,
- base_url=base_url,
- static_routes=routes,
- # add assets subdir as static_content
- static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"),
- ingress_tcp_site_params=ingress_tcp_site_params,
- )
-
- async def close(self) -> None:
- """Cleanup on exit."""
- for client in set(self.clients):
- await client.disconnect()
- await self._server.close()
-
- async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
- """Serve short preview sample."""
- provider_instance_id_or_domain = request.query["provider"]
- item_id = urllib.parse.unquote(request.query["item_id"])
- resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/aac"})
- await resp.prepare(request)
- async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
- await resp.write(chunk)
- return resp
-
- async def _handle_server_info(self, request: web.Request) -> web.Response:
- """Handle request for server info."""
- return web.json_response(self.mass.get_server_info().to_dict())
-
- async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
- connection = WebsocketClientHandler(self, request)
- if lang := request.headers.get("Accept-Language"):
- self.mass.metadata.set_default_preferred_language(lang.split(",")[0])
- try:
- self.clients.add(connection)
- return await connection.handle_client()
- finally:
- self.clients.remove(connection)
-
- async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
- """Handle incoming JSON RPC API command."""
- if not request.can_read_body:
- return web.Response(status=400, text="Body required")
- cmd_data = await request.read()
- self.logger.log(VERBOSE_LOG_LEVEL, "Received on JSONRPC API: %s", cmd_data)
- try:
- command_msg = CommandMessage.from_json(cmd_data)
- except ValueError:
- error = f"Invalid JSON: {cmd_data.decode()}"
- self.logger.error("Unhandled JSONRPC API error: %s", error)
- return web.Response(status=400, text=error)
- except MissingField as e:
- # be forgiving if message_id is missing
- cmd_data_dict = json_loads(cmd_data)
- if e.field_name == "message_id" and "command" in cmd_data_dict:
- cmd_data_dict["message_id"] = "unknown"
- command_msg = CommandMessage.from_dict(cmd_data_dict)
- else:
- error = f"Missing field in JSON: {e!s}"
- self.logger.error("Unhandled JSONRPC API error: %s", error)
- return web.Response(status=400, text=error)
-
- # work out handler for the given path/command
- handler = self.mass.command_handlers.get(command_msg.command)
- if handler is None:
- error = f"Invalid Command: {command_msg.command}"
- self.logger.error("Unhandled JSONRPC API error: %s", error)
- return web.Response(status=400, text=error)
- try:
- args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
- result: Any = handler.target(**args)
- if hasattr(result, "__anext__"):
- # handle async generator (for really large listings)
- result = [item async for item in result]
- elif asyncio.iscoroutine(result):
- result = await result
- return web.json_response(result, dumps=json_dumps)
- except Exception as e:
- # Return clean error message without stacktrace
- error_type = type(e).__name__
- error_msg = str(e)
- error = f"{error_type}: {error_msg}"
- self.logger.error("Error executing command %s: %s", command_msg.command, error)
- return web.Response(status=500, text=error)
-
- async def _handle_application_log(self, request: web.Request) -> web.Response:
- """Handle request to get the application log."""
- log_data = await self.mass.get_application_log()
- return web.Response(text=log_data, content_type="text/text")
-
- async def _handle_api_intro(self, request: web.Request) -> web.Response:
- """Handle request for API introduction/documentation page."""
- intro_html_path = os.path.join(
- os.path.dirname(__file__), "..", "helpers", "resources", "api_docs.html"
- )
- # Read the template
- async with aiofiles.open(intro_html_path) as f:
- html_content = await f.read()
-
- # Replace placeholders (escape values to prevent XSS)
- html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
- html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
- html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
-
- return web.Response(text=html_content, content_type="text/html")
-
- async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
- """Handle request for OpenAPI specification (generated on-the-fly)."""
- spec = generate_openapi_spec(
- self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
- )
- return web.json_response(spec)
-
- async def _handle_commands_reference(self, request: web.Request) -> web.Response:
- """Handle request for commands reference page (generated on-the-fly)."""
- html = generate_commands_reference(self.mass.command_handlers, server_url=self.base_url)
- return web.Response(text=html, content_type="text/html")
-
- async def _handle_schemas_reference(self, request: web.Request) -> web.Response:
- """Handle request for schemas reference page (generated on-the-fly)."""
- html = generate_schemas_reference(self.mass.command_handlers)
- return web.Response(text=html, content_type="text/html")
-
- async def _handle_swagger_ui(self, request: web.Request) -> web.FileResponse:
- """Handle request for Swagger UI."""
- swagger_html_path = os.path.join(
- os.path.dirname(__file__), "..", "helpers", "resources", "swagger_ui.html"
- )
- return await self._server.serve_static(swagger_html_path, request)
-
-
-class WebsocketClientHandler:
- """Handle an active websocket client connection."""
-
- def __init__(self, webserver: WebserverController, request: web.Request) -> None:
- """Initialize an active connection."""
- self.mass = webserver.mass
- self.request = request
- self.wsock = web.WebSocketResponse(heartbeat=55)
- self._to_write: asyncio.Queue[str | None] = asyncio.Queue(maxsize=MAX_PENDING_MSG)
- self._handle_task: asyncio.Task[Any] | None = None
- self._writer_task: asyncio.Task[None] | None = None
- self._logger = webserver.logger
- # try to dynamically detect the base_url of a client if proxied or behind Ingress
- self.base_url: str | None = None
- if forward_host := request.headers.get("X-Forwarded-Host"):
- ingress_path = request.headers.get("X-Ingress-Path", "")
- forward_proto = request.headers.get("X-Forwarded-Proto", request.protocol)
- self.base_url = f"{forward_proto}://{forward_host}{ingress_path}"
-
- async def disconnect(self) -> None:
- """Disconnect client."""
- self._cancel()
- if self._writer_task is not None:
- await self._writer_task
-
- async def handle_client(self) -> web.WebSocketResponse:
- """Handle a websocket response."""
- # ruff: noqa: PLR0915
- request = self.request
- wsock = self.wsock
- try:
- async with asyncio.timeout(10):
- await wsock.prepare(request)
- except TimeoutError:
- self._logger.warning("Timeout preparing request from %s", request.remote)
- return wsock
-
- self._logger.log(VERBOSE_LOG_LEVEL, "Connection from %s", request.remote)
- self._handle_task = asyncio.current_task()
- self._writer_task = self.mass.create_task(self._writer())
-
- # send server(version) info when client connects
- await self._send_message(self.mass.get_server_info())
-
- # forward all events to clients
- def handle_event(event: MassEvent) -> None:
- self._send_message_sync(event)
-
- unsub_callback = self.mass.subscribe(handle_event)
-
- disconnect_warn = None
-
- try:
- while not wsock.closed:
- msg = await wsock.receive()
-
- if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
- break
-
- if msg.type != WSMsgType.TEXT:
- continue
-
- self._logger.log(VERBOSE_LOG_LEVEL, "Received: %s", msg.data)
-
- try:
- command_msg = CommandMessage.from_json(msg.data)
- except ValueError:
- disconnect_warn = f"Received invalid JSON: {msg.data}"
- break
-
- await self._handle_command(command_msg)
-
- except asyncio.CancelledError:
- self._logger.debug("Connection closed by client")
-
- except Exception:
- self._logger.exception("Unexpected error inside websocket API")
-
- finally:
- # Handle connection shutting down.
- unsub_callback()
- self._logger.log(VERBOSE_LOG_LEVEL, "Unsubscribed from events")
-
- try:
- self._to_write.put_nowait(None)
- # Make sure all error messages are written before closing
- await self._writer_task
- await wsock.close()
- except asyncio.QueueFull: # can be raised by put_nowait
- self._writer_task.cancel()
-
- finally:
- if disconnect_warn is None:
- self._logger.log(VERBOSE_LOG_LEVEL, "Disconnected")
- else:
- self._logger.warning("Disconnected: %s", disconnect_warn)
-
- return wsock
-
- async def _handle_command(self, msg: CommandMessage) -> None:
- """Handle an incoming command from the client."""
- self._logger.debug("Handling command %s", msg.command)
-
- # work out handler for the given path/command
- handler = self.mass.command_handlers.get(msg.command)
-
- if handler is None:
- await self._send_message(
- ErrorResultMessage(
- msg.message_id,
- InvalidCommand.error_code,
- f"Invalid command: {msg.command}",
- )
- )
- self._logger.warning("Invalid command: %s", msg.command)
- return
-
- # schedule task to handle the command
- self.mass.create_task(self._run_handler(handler, msg))
-
- async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None:
- try:
- args = parse_arguments(handler.signature, handler.type_hints, msg.args)
- result: Any = handler.target(**args)
- if hasattr(result, "__anext__"):
- # handle async generator (for really large listings)
- items: list[Any] = []
- async for item in result:
- items.append(item)
- if len(items) >= 500:
- await self._send_message(
- SuccessResultMessage(msg.message_id, items, partial=True)
- )
- items = []
- result = items
- elif asyncio.iscoroutine(result):
- result = await result
- await self._send_message(SuccessResultMessage(msg.message_id, result))
- except Exception as err:
- if self._logger.isEnabledFor(logging.DEBUG):
- self._logger.exception("Error handling message: %s", msg)
- else:
- self._logger.error("Error handling message: %s: %s", msg.command, str(err))
- err_msg = str(err) or err.__class__.__name__
- await self._send_message(
- ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), err_msg)
- )
-
- async def _writer(self) -> None:
- """Write outgoing messages."""
- # Exceptions if Socket disconnected or cancelled by connection handler
- with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
- while not self.wsock.closed:
- if (process := await self._to_write.get()) is None:
- break
-
- if callable(process):
- message: str = process()
- else:
- message = process
- self._logger.log(VERBOSE_LOG_LEVEL, "Writing: %s", message)
- await self.wsock.send_str(message)
-
- async def _send_message(self, message: MessageType) -> None:
- """Send a message to the client (for large response messages).
-
- Runs JSON serialization in executor to avoid blocking for large messages.
- Closes connection if the client is not reading the messages.
-
- Async friendly.
- """
- # Run JSON serialization in executor to avoid blocking for large messages
- loop = asyncio.get_running_loop()
- _message = await loop.run_in_executor(None, message.to_json)
-
- try:
- self._to_write.put_nowait(_message)
- except asyncio.QueueFull:
- self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
-
- self._cancel()
-
- def _send_message_sync(self, message: MessageType) -> None:
- """Send a message from a sync context (for small messages like events).
-
- Serializes inline without executor overhead since events are typically small.
- """
- _message = message.to_json()
-
- try:
- self._to_write.put_nowait(_message)
- except asyncio.QueueFull:
- self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
-
- self._cancel()
-
- def _cancel(self) -> None:
- """Cancel the connection."""
- if self._handle_task is not None:
- self._handle_task.cancel()
- if self._writer_task is not None:
- self._writer_task.cancel()
--- /dev/null
+"""Webserver Controller for Music Assistant.
+
+Handles the built-in webserver that hosts the API, frontend, and authentication.
+"""
+
+from __future__ import annotations
+
+from .controller import WebserverController
+
+__all__ = ["WebserverController"]
--- /dev/null
+"""Helpers for generating API documentation and OpenAPI specifications."""
+
+from __future__ import annotations
+
+import collections.abc
+import inspect
+import re
+from collections.abc import Callable
+from dataclasses import MISSING
+from datetime import datetime
+from enum import Enum
+from types import NoneType, UnionType
+from typing import Any, Union, get_args, get_origin, get_type_hints
+
+from music_assistant_models.player import Player as PlayerState
+
+from music_assistant.helpers.api import APICommandHandler
+
+
+def _format_type_name(type_hint: Any) -> str:
+ """Format a type hint as a user-friendly string, using JSON types instead of Python types."""
+ if type_hint is NoneType or type_hint is type(None):
+ return "null"
+
+ # Handle internal Player model - replace with PlayerState
+ if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
+ if (
+ hasattr(type_hint, "__module__")
+ and type_hint.__module__ == "music_assistant.models.player"
+ ):
+ return "PlayerState"
+
+ # Handle PluginSource - replace with PlayerSource (parent type)
+ if hasattr(type_hint, "__name__") and type_hint.__name__ == "PluginSource":
+ if (
+ hasattr(type_hint, "__module__")
+ and type_hint.__module__ == "music_assistant.models.plugin"
+ ):
+ return "PlayerSource"
+
+ # Map Python types to JSON types
+ type_name_mapping = {
+ "str": "string",
+ "int": "integer",
+ "float": "number",
+ "bool": "boolean",
+ "dict": "object",
+ "list": "array",
+ "tuple": "array",
+ "set": "array",
+ "frozenset": "array",
+ "Sequence": "array",
+ "UniqueList": "array",
+ "None": "null",
+ }
+
+ if hasattr(type_hint, "__name__"):
+ type_name = str(type_hint.__name__)
+ return type_name_mapping.get(type_name, type_name)
+
+ type_str = str(type_hint).replace("NoneType", "null")
+ # Replace Python types with JSON types in complex type strings
+ for python_type, json_type in type_name_mapping.items():
+ type_str = type_str.replace(python_type, json_type)
+ return type_str
+
+
+def _generate_type_alias_description(type_alias: Any, alias_name: str) -> str:
+ """Generate a human-readable description of a type alias from its definition.
+
+ :param type_alias: The type alias to describe (e.g., ConfigValueType)
+ :param alias_name: The name of the alias for display
+ :return: A human-readable description string
+ """
+ # Get the union args
+ args = get_args(type_alias)
+ if not args:
+ return f"Type alias for {alias_name}."
+
+ # Convert each type to a readable name
+ type_names = []
+ for arg in args:
+ origin = get_origin(arg)
+ if origin in (list, tuple):
+ # Handle list types
+ inner_args = get_args(arg)
+ if inner_args:
+ inner_type = inner_args[0]
+ if inner_type is bool:
+ type_names.append("array of boolean")
+ elif inner_type is int:
+ type_names.append("array of integer")
+ elif inner_type is float:
+ type_names.append("array of number")
+ elif inner_type is str:
+ type_names.append("array of string")
+ else:
+ type_names.append(
+ f"array of {getattr(inner_type, '__name__', str(inner_type))}"
+ )
+ else:
+ type_names.append("array")
+ elif arg is type(None) or arg is NoneType:
+ type_names.append("null")
+ elif arg is bool:
+ type_names.append("boolean")
+ elif arg is int:
+ type_names.append("integer")
+ elif arg is float:
+ type_names.append("number")
+ elif arg is str:
+ type_names.append("string")
+ elif hasattr(arg, "__name__"):
+ type_names.append(arg.__name__)
+ else:
+ type_names.append(str(arg))
+
+ # Format the list nicely
+ if len(type_names) == 1:
+ types_str = type_names[0]
+ elif len(type_names) == 2:
+ types_str = f"{type_names[0]} or {type_names[1]}"
+ else:
+ types_str = f"{', '.join(type_names[:-1])}, or {type_names[-1]}"
+
+ return f"Type alias for {alias_name.lower()} types. Can be {types_str}."
+
+
+def _get_type_schema( # noqa: PLR0911, PLR0915
+ type_hint: Any, definitions: dict[str, Any]
+) -> dict[str, Any]:
+ """Convert a Python type hint to an OpenAPI schema."""
+ # Check if type_hint matches a type alias that was expanded by get_type_hints()
+ # Import type aliases to compare against
+ from music_assistant_models.config_entries import ( # noqa: PLC0415
+ ConfigValueType as config_value_type, # noqa: N813
+ )
+ from music_assistant_models.media_items import ( # noqa: PLC0415
+ MediaItemType as media_item_type, # noqa: N813
+ )
+
+ if type_hint == config_value_type:
+ # This is the expanded ConfigValueType, treat it as the type alias
+ return _get_type_schema("ConfigValueType", definitions)
+ if type_hint == media_item_type:
+ # This is the expanded MediaItemType, treat it as the type alias
+ return _get_type_schema("MediaItemType", definitions)
+
+ # Handle string type hints from __future__ annotations
+ if isinstance(type_hint, str):
+ # Handle simple primitive type names
+ if type_hint in ("str", "string"):
+ return {"type": "string"}
+ if type_hint in ("int", "integer"):
+ return {"type": "integer"}
+ if type_hint in ("float", "number"):
+ return {"type": "number"}
+ if type_hint in ("bool", "boolean"):
+ return {"type": "boolean"}
+
+ # Special handling for type aliases - create proper schema definitions
+ if type_hint == "ConfigValueType":
+ if "ConfigValueType" not in definitions:
+ from music_assistant_models.config_entries import ( # noqa: PLC0415
+ ConfigValueType as config_value_type, # noqa: N813
+ )
+
+ # Dynamically create oneOf schema with description from the actual type
+ cvt_args = get_args(config_value_type)
+ definitions["ConfigValueType"] = {
+ "description": _generate_type_alias_description(
+ config_value_type, "configuration value"
+ ),
+ "oneOf": [_get_type_schema(arg, definitions) for arg in cvt_args],
+ }
+ return {"$ref": "#/components/schemas/ConfigValueType"}
+
+ if type_hint == "MediaItemType":
+ if "MediaItemType" not in definitions:
+ from music_assistant_models.media_items import ( # noqa: PLC0415
+ MediaItemType as media_item_type, # noqa: N813
+ )
+
+ # Dynamically create oneOf schema with description from the actual type
+ mit_origin = get_origin(media_item_type)
+ if mit_origin in (Union, UnionType):
+ mit_args = get_args(media_item_type)
+ definitions["MediaItemType"] = {
+ "description": _generate_type_alias_description(
+ media_item_type, "media item"
+ ),
+ "oneOf": [_get_type_schema(arg, definitions) for arg in mit_args],
+ }
+ else:
+ definitions["MediaItemType"] = _get_type_schema(media_item_type, definitions)
+ return {"$ref": "#/components/schemas/MediaItemType"}
+
+ # Handle PluginSource - replace with PlayerSource (parent type)
+ if type_hint == "PluginSource":
+ return _get_type_schema("PlayerSource", definitions)
+
+ # Check if it looks like a simple class name (no special chars, starts with uppercase)
+ # Examples: "PlayerType", "DeviceInfo", "PlaybackState"
+ # Exclude generic types like "Any", "Union", "Optional", etc.
+ excluded_types = {"Any", "Union", "Optional", "List", "Dict", "Tuple", "Set"}
+ if type_hint.isidentifier() and type_hint[0].isupper() and type_hint not in excluded_types:
+ # Create a schema reference for this type
+ if type_hint not in definitions:
+ definitions[type_hint] = {"type": "object"}
+ return {"$ref": f"#/components/schemas/{type_hint}"}
+
+ # If it's "Any", return generic object without creating a schema
+ if type_hint == "Any":
+ return {"type": "object"}
+
+ # For complex type expressions like "str | None", "list[str]", return generic object
+ return {"type": "object"}
+
+ # Handle None type
+ if type_hint is NoneType or type_hint is type(None):
+ return {"type": "null"}
+
+ # Handle internal Player model - replace with external PlayerState
+ if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
+ # Check if this is the internal Player (from music_assistant.models.player)
+ if (
+ hasattr(type_hint, "__module__")
+ and type_hint.__module__ == "music_assistant.models.player"
+ ):
+ # Replace with PlayerState from music_assistant_models
+ return _get_type_schema(PlayerState, definitions)
+
+ # Handle PluginSource - replace with PlayerSource (parent type)
+ if hasattr(type_hint, "__name__") and type_hint.__name__ == "PluginSource":
+ # Check if this is PluginSource from music_assistant.models.plugin
+ if (
+ hasattr(type_hint, "__module__")
+ and type_hint.__module__ == "music_assistant.models.plugin"
+ ):
+ # Replace with PlayerSource from music_assistant.models.player
+ from music_assistant.models.player import PlayerSource # noqa: PLC0415
+
+ return _get_type_schema(PlayerSource, definitions)
+
+ # Handle Union types (including Optional)
+ origin = get_origin(type_hint)
+ if origin is Union or origin is UnionType:
+ args = get_args(type_hint)
+ # Check if it's Optional (Union with None)
+ non_none_args = [arg for arg in args if arg not in (NoneType, type(None))]
+ if (len(non_none_args) == 1 and NoneType in args) or type(None) in args:
+ # It's Optional[T], make it nullable
+ schema = _get_type_schema(non_none_args[0], definitions)
+ schema["nullable"] = True
+ return schema
+ # It's a union of multiple types
+ return {"oneOf": [_get_type_schema(arg, definitions) for arg in args]}
+
+ # Handle UniqueList (treat as array)
+ if hasattr(type_hint, "__name__") and type_hint.__name__ == "UniqueList":
+ args = get_args(type_hint)
+ if args:
+ return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+ return {"type": "array", "items": {}}
+
+ # Handle Sequence types (from collections.abc or typing)
+ if origin is collections.abc.Sequence or (
+ hasattr(origin, "__name__") and origin.__name__ == "Sequence"
+ ):
+ args = get_args(type_hint)
+ if args:
+ return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+ return {"type": "array", "items": {}}
+
+ # Handle set/frozenset types
+ if origin in (set, frozenset):
+ args = get_args(type_hint)
+ if args:
+ return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+ return {"type": "array", "items": {}}
+
+ # Handle list/tuple types
+ if origin in (list, tuple):
+ args = get_args(type_hint)
+ if args:
+ return {"type": "array", "items": _get_type_schema(args[0], definitions)}
+ return {"type": "array", "items": {}}
+
+ # Handle dict types
+ if origin is dict:
+ args = get_args(type_hint)
+ if len(args) == 2:
+ return {
+ "type": "object",
+ "additionalProperties": _get_type_schema(args[1], definitions),
+ }
+ return {"type": "object", "additionalProperties": True}
+
+ # Handle Enum types - add them to definitions as explorable objects
+ if inspect.isclass(type_hint) and issubclass(type_hint, Enum):
+ enum_name = type_hint.__name__
+ if enum_name not in definitions:
+ enum_values = [item.value for item in type_hint]
+ enum_type = type(enum_values[0]).__name__ if enum_values else "string"
+ openapi_type = {
+ "str": "string",
+ "int": "integer",
+ "float": "number",
+ "bool": "boolean",
+ }.get(enum_type, "string")
+
+ # Create a detailed enum definition with descriptions
+ enum_values_str = ", ".join(str(v) for v in enum_values)
+ definitions[enum_name] = {
+ "type": openapi_type,
+ "enum": enum_values,
+ "description": f"Enum: {enum_name}. Possible values: {enum_values_str}",
+ }
+ return {"$ref": f"#/components/schemas/{enum_name}"}
+
+ # Handle datetime
+ if type_hint is datetime:
+ return {"type": "string", "format": "date-time"}
+
+ # Handle primitive types - check both exact type and type name
+ if type_hint is str or (hasattr(type_hint, "__name__") and type_hint.__name__ == "str"):
+ return {"type": "string"}
+ if type_hint is int or (hasattr(type_hint, "__name__") and type_hint.__name__ == "int"):
+ return {"type": "integer"}
+ if type_hint is float or (hasattr(type_hint, "__name__") and type_hint.__name__ == "float"):
+ return {"type": "number"}
+ if type_hint is bool or (hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"):
+ return {"type": "boolean"}
+
+ # Handle complex types (dataclasses, models)
+ # Check for __annotations__ or if it's a class (not already handled above)
+ if hasattr(type_hint, "__annotations__") or (
+ inspect.isclass(type_hint) and not issubclass(type_hint, (str, int, float, bool, Enum))
+ ):
+ type_name = getattr(type_hint, "__name__", str(type_hint))
+ # Add to definitions if not already there
+ if type_name not in definitions:
+ properties = {}
+ required = []
+
+ # Check if this is a dataclass with fields
+ if hasattr(type_hint, "__dataclass_fields__"):
+ # Resolve type hints to handle forward references from __future__ annotations
+ try:
+ resolved_hints = get_type_hints(type_hint)
+ except Exception:
+ resolved_hints = {}
+
+ # Use dataclass fields to get proper info including defaults and metadata
+ for field_name, field_info in type_hint.__dataclass_fields__.items():
+ # Skip fields marked with serialize="omit" in metadata
+ if field_info.metadata:
+ # Check for mashumaro field_options
+ if "serialize" in field_info.metadata:
+ if field_info.metadata["serialize"] == "omit":
+ continue
+
+ # Use resolved type hint if available, otherwise fall back to field type
+ field_type = resolved_hints.get(field_name, field_info.type)
+ field_schema = _get_type_schema(field_type, definitions)
+
+ # Add default value if present
+ if field_info.default is not MISSING:
+ field_schema["default"] = field_info.default
+ elif (
+ hasattr(field_info, "default_factory")
+ and field_info.default_factory is not MISSING
+ ):
+ # Has a default factory - don't add anything, just skip
+ pass
+
+ properties[field_name] = field_schema
+
+ # Check if field is required (not Optional and no default)
+ has_default = field_info.default is not MISSING or (
+ hasattr(field_info, "default_factory")
+ and field_info.default_factory is not MISSING
+ )
+ is_optional = get_origin(field_type) in (
+ Union,
+ UnionType,
+ ) and NoneType in get_args(field_type)
+ if not has_default and not is_optional:
+ required.append(field_name)
+ elif hasattr(type_hint, "__annotations__"):
+ # Fallback for non-dataclass types with annotations
+ for field_name, field_type in type_hint.__annotations__.items():
+ properties[field_name] = _get_type_schema(field_type, definitions)
+ # Check if field is required (not Optional)
+ if not (
+ get_origin(field_type) in (Union, UnionType)
+ and NoneType in get_args(field_type)
+ ):
+ required.append(field_name)
+ else:
+ # Class without dataclass fields or annotations - treat as generic object
+ pass # Will create empty properties
+
+ definitions[type_name] = {
+ "type": "object",
+ "properties": properties,
+ }
+ if required:
+ definitions[type_name]["required"] = required
+
+ return {"$ref": f"#/components/schemas/{type_name}"}
+
+ # Handle Any
+ if type_hint is Any:
+ return {"type": "object"}
+
+ # Fallback - for types we don't recognize, at least return a generic object type
+ return {"type": "object"}
+
+
+def _parse_docstring( # noqa: PLR0915
+ func: Callable[..., Any],
+) -> tuple[str, str, dict[str, str]]:
+ """Parse docstring to extract summary, description and parameter descriptions.
+
+ Returns:
+ Tuple of (short_summary, full_description, param_descriptions)
+
+ Handles multiple docstring formats:
+ - reStructuredText (:param name: description)
+ - Google style (Args: section)
+ - NumPy style (Parameters section)
+ """
+ docstring = inspect.getdoc(func)
+ if not docstring:
+ return "", "", {}
+
+ lines = docstring.split("\n")
+ description_lines = []
+ param_descriptions = {}
+ current_section = "description"
+ current_param = None
+
+ for line in lines:
+ stripped = line.strip()
+
+ # Check for section headers
+ if stripped.lower() in ("args:", "arguments:", "parameters:", "params:"):
+ current_section = "params"
+ current_param = None
+ continue
+ if stripped.lower() in (
+ "returns:",
+ "return:",
+ "yields:",
+ "raises:",
+ "raises",
+ "examples:",
+ "example:",
+ "note:",
+ "notes:",
+ "see also:",
+ "warning:",
+ "warnings:",
+ ):
+ current_section = "other"
+ current_param = None
+ continue
+
+ # Parse :param style
+ if stripped.startswith(":param "):
+ current_section = "params"
+ parts = stripped[7:].split(":", 1)
+ if len(parts) == 2:
+ current_param = parts[0].strip()
+ desc = parts[1].strip()
+ if desc:
+ param_descriptions[current_param] = desc
+ continue
+
+ if stripped.startswith((":type ", ":rtype", ":return")):
+ current_section = "other"
+ current_param = None
+ continue
+
+ # Detect bullet-style params even without explicit section header
+ # Format: "- param_name: description"
+ if stripped.startswith("- ") and ":" in stripped:
+ # This is likely a bullet-style parameter
+ current_section = "params"
+ content = stripped[2:] # Remove "- "
+ parts = content.split(":", 1)
+ param_name = parts[0].strip()
+ desc_part = parts[1].strip() if len(parts) > 1 else ""
+ if param_name and not param_name.startswith(("return", "yield", "raise")):
+ current_param = param_name
+ if desc_part:
+ param_descriptions[current_param] = desc_part
+ continue
+
+ # In params section, detect param lines (indented or starting with name)
+ if current_section == "params" and stripped:
+ # Google/NumPy style: "param_name: description" or "param_name (type): description"
+ if ":" in stripped and not stripped.startswith(" "):
+ # Likely a parameter definition
+ if "(" in stripped and ")" in stripped:
+ # Format: param_name (type): description
+ param_part = stripped.split(":")[0]
+ param_name = param_part.split("(")[0].strip()
+ desc_part = ":".join(stripped.split(":")[1:]).strip()
+ else:
+ # Format: param_name: description
+ parts = stripped.split(":", 1)
+ param_name = parts[0].strip()
+ desc_part = parts[1].strip() if len(parts) > 1 else ""
+
+ if param_name and not param_name.startswith(("return", "yield", "raise")):
+ current_param = param_name
+ if desc_part:
+ param_descriptions[current_param] = desc_part
+ elif current_param and stripped:
+ # Continuation of previous parameter description
+ param_descriptions[current_param] = (
+ param_descriptions.get(current_param, "") + " " + stripped
+ ).strip()
+ continue
+
+ # Collect description lines (only before params/returns sections)
+ if current_section == "description" and stripped:
+ description_lines.append(stripped)
+ elif current_section == "description" and not stripped and description_lines:
+ # Empty line in description - keep it for paragraph breaks
+ description_lines.append("")
+
+ # Join description lines, removing excessive empty lines
+ description = "\n".join(description_lines).strip()
+ # Collapse multiple empty lines into one
+ while "\n\n\n" in description:
+ description = description.replace("\n\n\n", "\n\n")
+
+ # Extract first sentence/line as summary
+ summary = ""
+ if description:
+ # Get first line or first sentence (whichever is shorter)
+ first_line = description.split("\n")[0]
+ # Try to get first sentence (ending with .)
+ summary = first_line.split(".")[0] + "." if "." in first_line else first_line
+
+ return summary, description, param_descriptions
+
+
+def generate_openapi_spec(
+ command_handlers: dict[str, APICommandHandler],
+ server_url: str = "http://localhost:8095",
+ version: str = "1.0.0",
+) -> dict[str, Any]:
+ """Generate simplified OpenAPI 3.0 specification focusing on data models.
+
+ This spec documents the single /api endpoint and all data models/schemas.
+ For detailed command documentation, see the Commands Reference page.
+ """
+ definitions: dict[str, Any] = {}
+
+ # Build all schemas from command handlers (this populates definitions)
+ for handler in command_handlers.values():
+ # Skip aliases - they are for backward compatibility only
+ if handler.alias:
+ continue
+ # Build parameter schemas
+ for param_name in handler.signature.parameters:
+ if param_name == "self":
+ continue
+ # Skip return_type parameter (used only for type hints)
+ if param_name == "return_type":
+ continue
+ param_type = handler.type_hints.get(param_name, Any)
+ # Skip Any types as they don't provide useful schema information
+ if param_type is not Any and str(param_type) != "typing.Any":
+ _get_type_schema(param_type, definitions)
+
+ # Build return type schema
+ return_type = handler.type_hints.get("return", Any)
+ # Skip Any types as they don't provide useful schema information
+ if return_type is not Any and str(return_type) != "typing.Any":
+ _get_type_schema(return_type, definitions)
+
+ # Build a single /api endpoint with generic request/response
+ paths = {
+ "/api": {
+ "post": {
+ "summary": "Execute API command",
+ "description": (
+ "Execute any Music Assistant API command.\n\n"
+ "See the **Commands Reference** page for a complete list of available "
+ "commands with examples."
+ ),
+ "operationId": "execute_command",
+ "security": [{"bearerAuth": []}],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["command"],
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": (
+ "The command to execute (e.g., 'players/all')"
+ ),
+ "example": "players/all",
+ },
+ "args": {
+ "type": "object",
+ "description": "Command arguments (varies by command)",
+ "additionalProperties": True,
+ "example": {},
+ },
+ },
+ },
+ "examples": {
+ "get_players": {
+ "summary": "Get all players",
+ "value": {"command": "players/all", "args": {}},
+ },
+ "play_media": {
+ "summary": "Play media on a player",
+ "value": {
+ "command": "players/cmd/play",
+ "args": {"player_id": "player123"},
+ },
+ },
+ },
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Successful command execution",
+ "content": {
+ "application/json": {
+ "schema": {"description": "Command result (varies by command)"}
+ }
+ },
+ },
+ "400": {"description": "Bad request - invalid command or parameters"},
+ "401": {"description": "Unauthorized - authentication required"},
+ "403": {"description": "Forbidden - insufficient permissions"},
+ "500": {"description": "Internal server error"},
+ },
+ }
+ },
+ "/auth/login": {
+ "post": {
+ "summary": "Authenticate with credentials",
+ "description": "Login with username and password to obtain an access token.",
+ "operationId": "auth_login",
+ "tags": ["Authentication"],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "provider_id": {
+ "type": "string",
+ "description": "Auth provider ID (defaults to 'builtin')",
+ "example": "builtin",
+ },
+ "credentials": {
+ "type": "object",
+ "description": "Provider-specific credentials",
+ "properties": {
+ "username": {"type": "string"},
+ "password": {"type": "string"},
+ },
+ },
+ },
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Login successful",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {"type": "boolean"},
+ "token": {"type": "string"},
+ "user": {"type": "object"},
+ },
+ }
+ }
+ },
+ },
+ "400": {"description": "Invalid credentials"},
+ },
+ }
+ },
+ "/auth/providers": {
+ "get": {
+ "summary": "Get available auth providers",
+ "description": "Returns list of configured authentication providers.",
+ "operationId": "auth_providers",
+ "tags": ["Authentication"],
+ "responses": {
+ "200": {
+ "description": "List of auth providers",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "providers": {
+ "type": "array",
+ "items": {"type": "object"},
+ }
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ "/setup": {
+ "post": {
+ "summary": "Initial server setup",
+ "description": (
+ "Handle initial setup of the Music Assistant server including creating "
+ "the first admin user. Only accessible when no users exist "
+ "(onboard_done=false)."
+ ),
+ "operationId": "setup",
+ "tags": ["Server"],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["username", "password"],
+ "properties": {
+ "username": {"type": "string"},
+ "password": {"type": "string"},
+ "display_name": {"type": "string"},
+ },
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Setup completed successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {"type": "boolean"},
+ "token": {"type": "string"},
+ "user": {"type": "object"},
+ },
+ }
+ }
+ },
+ },
+ "400": {"description": "Setup already completed or invalid request"},
+ },
+ }
+ },
+ "/info": {
+ "get": {
+ "summary": "Get server info",
+ "description": (
+ "Returns server information including schema version and authentication status."
+ ),
+ "operationId": "get_info",
+ "tags": ["Server"],
+ "responses": {
+ "200": {
+ "description": "Server information",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "schema_version": {"type": "integer"},
+ "server_version": {"type": "string"},
+ "onboard_done": {"type": "boolean"},
+ "homeassistant_addon": {"type": "boolean"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ }
+
+ # Build OpenAPI spec
+ return {
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Music Assistant API",
+ "version": version,
+ "description": (
+ "Music Assistant API provides control over your music library, "
+ "players, and playback.\n\n"
+ "This specification documents the API structure and data models. "
+ "For a complete list of available commands with examples, "
+ "see the Commands Reference page."
+ ),
+ "contact": {
+ "name": "Music Assistant",
+ "url": "https://music-assistant.io",
+ },
+ },
+ "servers": [{"url": server_url, "description": "Music Assistant Server"}],
+ "paths": paths,
+ "components": {
+ "schemas": definitions,
+ "securitySchemes": {
+ "bearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "description": "Access token obtained from /auth/login or /auth/setup",
+ }
+ },
+ },
+ }
+
+
+def _split_union_type(type_str: str) -> list[str]:
+ """Split a union type on | but respect brackets and parentheses.
+
+ This ensures that list[A | B] and (A | B) are not split at the inner |.
+ """
+ parts = []
+ current_part = ""
+ bracket_depth = 0
+ paren_depth = 0
+ i = 0
+ while i < len(type_str):
+ char = type_str[i]
+ if char == "[":
+ bracket_depth += 1
+ current_part += char
+ elif char == "]":
+ bracket_depth -= 1
+ current_part += char
+ elif char == "(":
+ paren_depth += 1
+ current_part += char
+ elif char == ")":
+ paren_depth -= 1
+ current_part += char
+ elif char == "|" and bracket_depth == 0 and paren_depth == 0:
+ # Check if this is a union separator (has space before and after)
+ if (
+ i > 0
+ and i < len(type_str) - 1
+ and type_str[i - 1] == " "
+ and type_str[i + 1] == " "
+ ):
+ parts.append(current_part.strip())
+ current_part = ""
+ i += 1 # Skip the space after |, the loop will handle incrementing i
+ else:
+ current_part += char
+ else:
+ current_part += char
+ i += 1
+ if current_part.strip():
+ parts.append(current_part.strip())
+ return parts
+
+
+def _extract_generic_inner_type(type_str: str) -> str | None:
+ """Extract inner type from generic type like list[T] or dict[K, V].
+
+ :param type_str: Type string like "list[str]" or "dict[str, int]"
+ :return: Inner type string "str" or "str, int", or None if not a complete generic type
+ """
+ # Find the matching closing bracket
+ bracket_count = 0
+ start_idx = type_str.index("[") + 1
+ end_idx = -1
+ for i in range(start_idx, len(type_str)):
+ if type_str[i] == "[":
+ bracket_count += 1
+ elif type_str[i] == "]":
+ if bracket_count == 0:
+ end_idx = i
+ break
+ bracket_count -= 1
+
+ # Check if this is a complete generic type (ends with the closing bracket)
+ if end_idx == len(type_str) - 1:
+ return type_str[start_idx:end_idx].strip()
+ return None
+
+
+def _parse_dict_type_params(inner_type: str) -> tuple[str, str] | None:
+ """Parse key and value types from dict inner type string.
+
+ :param inner_type: The content inside dict[...], e.g., "str, ConfigValueType"
+ :return: Tuple of (key_type, value_type) or None if parsing fails
+ """
+ # Split on comma to get key and value types
+ # Need to be careful with nested types like dict[str, list[int]]
+ parts = []
+ current_part = ""
+ bracket_depth = 0
+ for char in inner_type:
+ if char == "[":
+ bracket_depth += 1
+ current_part += char
+ elif char == "]":
+ bracket_depth -= 1
+ current_part += char
+ elif char == "," and bracket_depth == 0:
+ parts.append(current_part.strip())
+ current_part = ""
+ else:
+ current_part += char
+ if current_part:
+ parts.append(current_part.strip())
+
+ if len(parts) == 2:
+ return parts[0], parts[1]
+ return None
+
+
+def _python_type_to_json_type(type_str: str, _depth: int = 0) -> str:
+ """Convert Python type string to JSON/JavaScript type string.
+
+ Args:
+ type_str: The type string to convert
+ _depth: Internal recursion depth tracker (do not set manually)
+ """
+ # Prevent infinite recursion
+ if _depth > 50:
+ return "any"
+
+ # Remove typing module prefix and class markers
+ type_str = type_str.replace("typing.", "").replace("<class '", "").replace("'>", "")
+
+ # Remove module paths from type names (e.g., "music_assistant.models.Artist" -> "Artist")
+ type_str = re.sub(r"[\w.]+\.(\w+)", r"\1", type_str)
+
+ # Check for type aliases that should be preserved as-is
+ # These will have schema definitions in the API docs
+ if type_str in ("ConfigValueType", "MediaItemType"):
+ return type_str
+
+ # Map Python types to JSON types
+ type_mappings = {
+ "str": "string",
+ "int": "integer",
+ "float": "number",
+ "bool": "boolean",
+ "dict": "object",
+ "Dict": "object",
+ "list": "array",
+ "tuple": "array",
+ "Tuple": "array",
+ "None": "null",
+ "NoneType": "null",
+ }
+
+ # Check for List/list/UniqueList/tuple with type parameter BEFORE checking for union types
+ # This is important because list[A | B] contains " | " but should be handled as a list first
+ # codespell:ignore
+ if type_str.startswith(("list[", "List[", "UniqueList[", "tuple[", "Tuple[")):
+ inner_type = _extract_generic_inner_type(type_str)
+ if inner_type:
+ # Handle variable-length tuple (e.g., tuple[str, ...])
+ # The ellipsis means "variable length of this type"
+ if inner_type.endswith(", ..."):
+ # Remove the ellipsis and just use the type
+ inner_type = inner_type[:-5].strip()
+ # Recursively convert the inner type
+ inner_json_type = _python_type_to_json_type(inner_type, _depth + 1)
+ # For list[A | B], wrap in parentheses to keep it as one unit
+ # This prevents "Array of A | B" from being split into separate union parts
+ if " | " in inner_json_type:
+ return f"Array of ({inner_json_type})"
+ return f"Array of {inner_json_type}"
+
+ # Check for dict/Dict with type parameters BEFORE checking for union types
+ # This is important because dict[str, A | B] contains " | "
+ # but should be handled as a dict first
+ # codespell:ignore
+ if type_str.startswith(("dict[", "Dict[")):
+ inner_type = _extract_generic_inner_type(type_str)
+ if inner_type:
+ parsed = _parse_dict_type_params(inner_type)
+ if parsed:
+ key_type_str, value_type_str = parsed
+ key_type = _python_type_to_json_type(key_type_str, _depth + 1)
+ value_type = _python_type_to_json_type(value_type_str, _depth + 1)
+ # Use more descriptive format: "object with {key_type} keys and {value_type} values"
+ return f"object with {key_type} keys and {value_type} values"
+
+ # Handle Union types by splitting on | and recursively processing each part
+ if " | " in type_str:
+ # Use helper to split on | but respect brackets
+ parts = _split_union_type(type_str)
+
+ # Filter out None/null types (None, NoneType, null all mean JSON null)
+ parts = [part for part in parts if part not in ("None", "NoneType", "null")]
+
+ # If splitting didn't help (only one part or same as input), avoid infinite recursion
+ if not parts or (len(parts) == 1 and parts[0] == type_str):
+ # Can't split further, return as-is or "any"
+ return type_str if parts else "any"
+
+ if parts:
+ converted_parts = [_python_type_to_json_type(part, _depth + 1) for part in parts]
+ # Remove duplicates while preserving order
+ seen = set()
+ unique_parts = []
+ for part in converted_parts:
+ if part not in seen:
+ seen.add(part)
+ unique_parts.append(part)
+ return " | ".join(unique_parts)
+ return "any"
+
+ # Check for Union/Optional types with brackets
+ if "Union[" in type_str or "Optional[" in type_str:
+ # Extract content from Union[...] or Optional[...]
+ union_match = re.search(r"(?:Union|Optional)\[([^\]]+)\]", type_str)
+ if union_match:
+ inner = union_match.group(1)
+ # Recursively process the union content
+ return _python_type_to_json_type(inner, _depth + 1)
+
+ # Direct mapping for basic types
+ for py_type, json_type in type_mappings.items():
+ if type_str == py_type:
+ return json_type
+
+ # Check if it's a complex type (starts with capital letter)
+ complex_match = re.search(r"^([A-Z][a-zA-Z0-9_]*)$", type_str)
+ if complex_match:
+ return complex_match.group(1)
+
+ # Default to the original string if no mapping found
+ return type_str
+
+
+def _make_type_links(type_str: str, server_url: str, as_list: bool = False) -> str:
+ """Convert type string to HTML with links to schemas reference for complex types.
+
+ Args:
+ type_str: The type string to convert
+ server_url: Base server URL for building links
+ as_list: If True and type contains |, format as "Any of:" bullet list
+ """
+
+ # Find all complex types (capitalized words that aren't basic types)
+ def replace_type(match: re.Match[str]) -> str:
+ type_name = match.group(0)
+ # Check if it's a complex type (starts with capital letter)
+ # Exclude basic types and "Array" (which is used in "Array of Type")
+ excluded = {"Union", "Optional", "List", "Dict", "Array", "None", "NoneType"}
+ if type_name[0].isupper() and type_name not in excluded:
+ # Create link to our schemas reference page
+ schema_url = f"{server_url}/api-docs/schemas#schema-{type_name}"
+ return f'<a href="{schema_url}" class="type-link">{type_name}</a>'
+ return type_name
+
+ # If it's a union type with multiple options and as_list is True, format as bullet list
+ if as_list and " | " in type_str:
+ # Use the bracket/parenthesis-aware splitter
+ parts = _split_union_type(type_str)
+ # Only use list format if there are 3+ options
+ if len(parts) >= 3:
+ html = '<div class="type-union"><span class="type-union-label">Any of:</span><ul>'
+ for part in parts:
+ linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part)
+ html += f"<li>{linked_part}</li>"
+ html += "</ul></div>"
+ return html
+
+ # Replace complex type names with links
+ result: str = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, type_str)
+ return result
+
+
+def generate_commands_json(command_handlers: dict[str, APICommandHandler]) -> list[dict[str, Any]]:
+ """Generate JSON representation of all available API commands.
+
+ This is used by client libraries to sync their methods with the server API.
+
+ Returns a list of command objects with the following structure:
+ {
+ "command": str, # Command name (e.g., "music/tracks/library_items")
+ "category": str, # Category (e.g., "Music")
+ "summary": str, # Short description
+ "description": str, # Full description
+ "parameters": [ # List of parameters
+ {
+ "name": str,
+ "type": str, # JSON type (string, integer, boolean, etc.)
+ "required": bool,
+ "description": str
+ }
+ ],
+ "return_type": str, # Return type
+ "authenticated": bool, # Whether authentication is required
+ "required_role": str | None, # Required user role (if any)
+ }
+ """
+ commands_data = []
+
+ for command, handler in sorted(command_handlers.items()):
+ # Skip aliases - they are for backward compatibility only
+ if handler.alias:
+ continue
+ # Parse docstring
+ summary, description, param_descriptions = _parse_docstring(handler.target)
+
+ # Get return type
+ return_type = handler.type_hints.get("return", Any)
+ # If type is already a string (e.g., "ConfigValueType"), use it directly
+ return_type_str = _python_type_to_json_type(
+ return_type if isinstance(return_type, str) else str(return_type)
+ )
+
+ # Extract category from command name
+ category = command.split("/")[0] if "/" in command else "general"
+ category_display = category.replace("_", " ").title()
+
+ # Build parameters list
+ parameters = []
+ for param_name, param in handler.signature.parameters.items():
+ if param_name in ("self", "return_type"):
+ continue
+
+ is_required = param.default is inspect.Parameter.empty
+ param_type = handler.type_hints.get(param_name, Any)
+ # If type is already a string (e.g., "ConfigValueType"), use it directly
+ type_str = param_type if isinstance(param_type, str) else str(param_type)
+ json_type_str = _python_type_to_json_type(type_str)
+ param_desc = param_descriptions.get(param_name, "")
+
+ parameters.append(
+ {
+ "name": param_name,
+ "type": json_type_str,
+ "required": is_required,
+ "description": param_desc,
+ }
+ )
+
+ commands_data.append(
+ {
+ "command": command,
+ "category": category_display,
+ "summary": summary or "",
+ "description": description or "",
+ "parameters": parameters,
+ "return_type": return_type_str,
+ "authenticated": handler.authenticated,
+ "required_role": handler.required_role,
+ }
+ )
+
+ return commands_data
+
+
+def generate_schemas_json(command_handlers: dict[str, APICommandHandler]) -> dict[str, Any]:
+ """Generate JSON representation of all schemas/data models.
+
+ Returns a dict mapping schema names to their OpenAPI schema definitions.
+ """
+ schemas: dict[str, Any] = {}
+
+ for handler in command_handlers.values():
+ # Skip aliases - they are for backward compatibility only
+ if handler.alias:
+ continue
+ # Collect schemas from parameters
+ for param_name in handler.signature.parameters:
+ if param_name == "self":
+ continue
+ # Skip return_type parameter (used only for type hints)
+ if param_name == "return_type":
+ continue
+ param_type = handler.type_hints.get(param_name, Any)
+ if param_type is not Any and str(param_type) != "typing.Any":
+ _get_type_schema(param_type, schemas)
+
+ # Collect schemas from return type
+ return_type = handler.type_hints.get("return", Any)
+ if return_type is not Any and str(return_type) != "typing.Any":
+ _get_type_schema(return_type, schemas)
+
+ return schemas
--- /dev/null
+"""Authentication manager for Music Assistant webserver."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import secrets
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.auth import (
+ AuthProviderType,
+ AuthToken,
+ User,
+ UserAuthProvider,
+ UserRole,
+)
+from music_assistant_models.errors import (
+ AuthenticationFailed,
+ AuthenticationRequired,
+ InsufficientPermissions,
+ InvalidDataError,
+)
+
+from music_assistant.constants import (
+ CONF_AUTH_ALLOW_SELF_REGISTRATION,
+ CONF_ONBOARD_DONE,
+ HOMEASSISTANT_SYSTEM_USER,
+ MASS_LOGGER_NAME,
+)
+from music_assistant.controllers.webserver.helpers.auth_middleware import (
+ get_current_token,
+ get_current_user,
+)
+from music_assistant.controllers.webserver.helpers.auth_providers import (
+ AuthResult,
+ BuiltinLoginProvider,
+ HomeAssistantOAuthProvider,
+ HomeAssistantProviderConfig,
+ LoginProvider,
+ LoginProviderConfig,
+)
+from music_assistant.helpers.api import api_command
+from music_assistant.helpers.database import DatabaseConnection
+from music_assistant.helpers.datetime import utc
+from music_assistant.helpers.json import json_dumps, json_loads
+
+if TYPE_CHECKING:
+ from music_assistant.controllers.webserver import WebserverController
+
+LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth")
+
+# Database schema version
+DB_SCHEMA_VERSION = 2
+
+# Token expiration constants (in days)
+TOKEN_SHORT_LIVED_EXPIRATION = 30 # Short-lived tokens (auto-renewing on use)
+TOKEN_LONG_LIVED_EXPIRATION = 3650 # Long-lived tokens (10 years, no auto-renewal)
+
+
+class AuthenticationManager:
+ """Manager for authentication and user management (part of webserver controller)."""
+
+ def __init__(self, webserver: WebserverController) -> None:
+ """
+ Initialize the authentication manager.
+
+ :param webserver: WebserverController instance.
+ """
+ self.webserver = webserver
+ self.mass = webserver.mass
+ self.database: DatabaseConnection = None # type: ignore[assignment]
+ self.login_providers: dict[str, LoginProvider] = {}
+ self.logger = LOGGER
+
+ async def setup(self) -> None:
+ """Initialize the authentication manager."""
+ # Get auth settings from config
+ allow_self_registration = self.webserver.config.get_value(CONF_AUTH_ALLOW_SELF_REGISTRATION)
+ assert isinstance(allow_self_registration, bool)
+
+ # Setup database
+ db_path = self.mass.storage_path + "/auth.db"
+ self.database = DatabaseConnection(db_path)
+ await self.database.setup()
+
+ # Create database schema and handle migrations
+ await self._setup_database()
+
+ # Setup login providers based on config
+ await self._setup_login_providers(allow_self_registration)
+
+ # Migration: Reset onboard_done if no users exist
+ # This handles existing setups where authentication was optional
+ if self.mass.config.onboard_done and not await self.has_users():
+ self.logger.warning(
+ "Authentication is mandatory but no users exist. "
+ "Resetting onboard_done to redirect to setup."
+ )
+ self.mass.config.set(CONF_ONBOARD_DONE, False)
+ self.mass.config.save(immediate=True)
+
+ self.logger.info(
+ "Authentication manager initialized (providers=%d)", len(self.login_providers)
+ )
+
+ async def close(self) -> None:
+ """Cleanup on exit."""
+ if self.database:
+ await self.database.close()
+
+ async def _setup_database(self) -> None:
+ """Set up database schema and handle migrations."""
+ # Always create tables if they don't exist
+ await self._create_database_tables()
+
+ # Check current schema version
+ try:
+ if db_row := await self.database.get_row("settings", {"key": "schema_version"}):
+ prev_version = int(db_row["value"])
+ else:
+ prev_version = 0
+ except (KeyError, ValueError, Exception):
+ # settings table doesn't exist yet or other error
+ prev_version = 0
+
+ # Perform migration if needed
+ if prev_version < DB_SCHEMA_VERSION:
+ self.logger.warning(
+ "Performing database migration from schema version %s to %s",
+ prev_version,
+ DB_SCHEMA_VERSION,
+ )
+ await self._migrate_database(prev_version)
+
+ # Store current schema version
+ await self.database.insert_or_replace(
+ "settings",
+ {"key": "schema_version", "value": str(DB_SCHEMA_VERSION), "type": "int"},
+ )
+
+ # Create indexes
+ await self._create_database_indexes()
+ await self.database.commit()
+
+ async def _create_database_tables(self) -> None:
+ """Create database tables."""
+ # Settings table (for schema version and other settings)
+ await self.database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ type TEXT
+ )
+ """
+ )
+
+ # Users table (decoupled from auth providers)
+ await self.database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ user_id TEXT PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ role TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1,
+ created_at TEXT NOT NULL,
+ display_name TEXT,
+ avatar_url TEXT,
+ preferences TEXT DEFAULT '{}'
+ )
+ """
+ )
+
+ # User auth provider links (many-to-many)
+ await self.database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_auth_providers (
+ link_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ provider_type TEXT NOT NULL,
+ provider_user_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(provider_type, provider_user_id),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ )
+ """
+ )
+
+ # Auth tokens table
+ await self.database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS auth_tokens (
+ token_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT,
+ last_used_at TEXT,
+ is_long_lived INTEGER DEFAULT 0,
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ )
+ """
+ )
+
+ await self.database.commit()
+
+ async def _create_database_indexes(self) -> None:
+ """Create database indexes."""
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_auth_providers_user "
+ "ON user_auth_providers(user_id)"
+ )
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_auth_providers_provider "
+ "ON user_auth_providers(provider_type, provider_user_id)"
+ )
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS idx_tokens_user ON auth_tokens(user_id)"
+ )
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS idx_tokens_hash ON auth_tokens(token_hash)"
+ )
+
+ async def _migrate_database(self, from_version: int) -> None:
+ """Perform database migration.
+
+ :param from_version: The schema version to migrate from.
+ """
+ self.logger.info(
+ "Migrating auth database from version %s to %s", from_version, DB_SCHEMA_VERSION
+ )
+ # Migration to version 2: Recreate tables due to password salt breaking change
+ if from_version < 2:
+ # Drop all auth-related tables
+ await self.database.execute("DROP TABLE IF EXISTS auth_tokens")
+ await self.database.execute("DROP TABLE IF EXISTS user_auth_providers")
+ await self.database.execute("DROP TABLE IF EXISTS users")
+ await self.database.commit()
+
+ # Recreate tables with current schema
+ await self._create_database_tables()
+
+ async def _setup_login_providers(self, allow_self_registration: bool) -> None:
+ """
+ Set up available login providers based on configuration.
+
+ :param allow_self_registration: Whether to allow self-registration via OAuth.
+ """
+ # Always enable built-in provider
+ builtin_config: LoginProviderConfig = {"allow_self_registration": False}
+ self.login_providers["builtin"] = BuiltinLoginProvider(self.mass, "builtin", builtin_config)
+
+ # Home Assistant OAuth provider
+ # Automatically enabled if HA provider (plugin) is configured
+ ha_provider = None
+ for provider in self.mass.providers:
+ if provider.domain == "hass" and provider.available:
+ ha_provider = provider
+ break
+
+ if ha_provider:
+ # Get URL from the HA provider config
+ ha_url = ha_provider.config.get_value("url")
+ assert isinstance(ha_url, str)
+ ha_config: HomeAssistantProviderConfig = {
+ "ha_url": ha_url,
+ "allow_self_registration": allow_self_registration,
+ }
+ self.login_providers["homeassistant"] = HomeAssistantOAuthProvider(
+ self.mass, "homeassistant", ha_config
+ )
+ self.logger.info(
+ "Home Assistant OAuth provider enabled (using URL from HA provider: %s)",
+ ha_url,
+ )
+
+ async def _sync_ha_oauth_provider(self) -> None:
+ """
+ Sync HA OAuth provider with HA provider availability (dynamic check).
+
+ Adds the provider if HA is available, removes it if HA is not available.
+ """
+ # Find HA provider
+ ha_provider = None
+ for provider in self.mass.providers:
+ if provider.domain == "hass" and provider.available:
+ ha_provider = provider
+ break
+
+ if ha_provider:
+ # HA provider exists and is available - ensure OAuth provider is registered
+ if "homeassistant" not in self.login_providers:
+ # Get allow_self_registration config
+ allow_self_registration = bool(
+ self.webserver.config.get_value(CONF_AUTH_ALLOW_SELF_REGISTRATION, True)
+ )
+
+ # Get URL from the HA provider config
+ ha_url = ha_provider.config.get_value("url")
+ assert isinstance(ha_url, str)
+ ha_config: HomeAssistantProviderConfig = {
+ "ha_url": ha_url,
+ "allow_self_registration": allow_self_registration,
+ }
+ self.login_providers["homeassistant"] = HomeAssistantOAuthProvider(
+ self.mass, "homeassistant", ha_config
+ )
+ self.logger.info(
+ "Home Assistant OAuth provider dynamically enabled (using URL: %s)",
+ ha_url,
+ )
+ # HA provider not available - remove OAuth provider if present
+ elif "homeassistant" in self.login_providers:
+ del self.login_providers["homeassistant"]
+ self.logger.info("Home Assistant OAuth provider removed (HA provider not available)")
+
+ async def has_users(self) -> bool:
+ """Check if any users exist in the system."""
+ count = await self.database.get_count("users")
+ return count > 0
+
+ async def authenticate_with_credentials(
+ self, provider_id: str, credentials: dict[str, Any]
+ ) -> AuthResult:
+ """
+ Authenticate a user with credentials.
+
+ :param provider_id: The login provider ID.
+ :param credentials: Provider-specific credentials.
+ """
+ provider = self.login_providers.get(provider_id)
+ if not provider:
+ return AuthResult(success=False, error="Invalid provider")
+
+ return await provider.authenticate(credentials)
+
+ async def authenticate_with_token(self, token: str) -> User | None:
+ """
+ Authenticate a user with an access token.
+
+ :param token: The access token.
+ """
+ # Hash the token to look it up
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+
+ # Find token in database
+ token_row = await self.database.get_row("auth_tokens", {"token_hash": token_hash})
+ if not token_row:
+ return None
+
+ # Check if token is expired
+ if token_row["expires_at"]:
+ expires_at = datetime.fromisoformat(token_row["expires_at"])
+ if utc() > expires_at:
+ # Token expired, delete it
+ await self.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
+ return None
+
+ # Implement sliding expiration for short-lived tokens
+ is_long_lived = bool(token_row["is_long_lived"])
+ now = utc()
+ updates = {"last_used_at": now.isoformat()}
+
+ if not is_long_lived and token_row["expires_at"]:
+ # Short-lived token: extend expiration on each use (sliding window)
+ new_expires_at = now + timedelta(days=TOKEN_SHORT_LIVED_EXPIRATION)
+ updates["expires_at"] = new_expires_at.isoformat()
+
+ # Update last used timestamp and potentially expiration
+ await self.database.update(
+ "auth_tokens",
+ {"token_id": token_row["token_id"]},
+ updates,
+ )
+
+ # Get user
+ return await self.get_user(token_row["user_id"])
+
+ async def get_token_id_from_token(self, token: str) -> str | None:
+ """
+ Get token_id from a token string (for tracking revocation).
+
+ :param token: The access token.
+ :return: The token_id or None if token not found.
+ """
+ # Hash the token to look it up
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+
+ # Find token in database
+ token_row = await self.database.get_row("auth_tokens", {"token_hash": token_hash})
+ if not token_row:
+ return None
+
+ return str(token_row["token_id"])
+
+ @api_command("auth/user", required_role="admin")
+ async def get_user(self, user_id: str) -> User | None:
+ """
+ Get user by ID (admin only).
+
+ :param user_id: The user ID.
+ :return: User object or None if not found.
+ """
+ user_row = await self.database.get_row("users", {"user_id": user_id})
+ if not user_row or not user_row["enabled"]:
+ return None
+
+ # Convert Row to dict for easier handling of optional fields
+ user_dict = dict(user_row)
+
+ # Parse preferences from JSON
+ preferences = {}
+ if prefs_json := user_dict.get("preferences"):
+ try:
+ preferences = json_loads(prefs_json)
+ except Exception:
+ self.logger.warning("Failed to parse preferences for user %s", user_id)
+
+ return User(
+ user_id=user_dict["user_id"],
+ username=user_dict["username"],
+ role=UserRole(user_dict["role"]),
+ enabled=bool(user_dict["enabled"]),
+ created_at=datetime.fromisoformat(user_dict["created_at"]),
+ display_name=user_dict.get("display_name"),
+ avatar_url=user_dict.get("avatar_url"),
+ preferences=preferences,
+ )
+
+ async def get_user_by_provider_link(
+ self, provider_type: AuthProviderType, provider_user_id: str
+ ) -> User | None:
+ """
+ Get user by their provider link.
+
+ :param provider_type: The auth provider type.
+ :param provider_user_id: The user ID from the provider.
+ """
+ link_row = await self.database.get_row(
+ "user_auth_providers",
+ {
+ "provider_type": provider_type.value,
+ "provider_user_id": provider_user_id,
+ },
+ )
+ if not link_row:
+ return None
+
+ return await self.get_user(link_row["user_id"])
+
+ async def create_user(
+ self,
+ username: str,
+ role: UserRole = UserRole.USER,
+ display_name: str | None = None,
+ avatar_url: str | None = None,
+ preferences: dict[str, Any] | None = None,
+ ) -> User:
+ """
+ Create a new user.
+
+ :param username: The username.
+ :param role: The user role (default: USER).
+ :param display_name: Optional display name.
+ :param avatar_url: Optional avatar URL.
+ :param preferences: Optional user preferences dict.
+ """
+ user_id = secrets.token_urlsafe(32)
+ created_at = utc()
+ if preferences is None:
+ preferences = {}
+
+ user_data = {
+ "user_id": user_id,
+ "username": username,
+ "role": role.value,
+ "enabled": True,
+ "created_at": created_at.isoformat(),
+ "display_name": display_name,
+ "avatar_url": avatar_url,
+ "preferences": json_dumps(preferences),
+ }
+
+ await self.database.insert("users", user_data)
+
+ return User(
+ user_id=user_id,
+ username=username,
+ role=role,
+ enabled=True,
+ created_at=created_at,
+ display_name=display_name,
+ avatar_url=avatar_url,
+ preferences=preferences,
+ )
+
+ async def get_homeassistant_system_user(self) -> User:
+ """
+ Get or create the Home Assistant system user.
+
+ This is a special system user created automatically for Home Assistant integration.
+ It bypasses normal authentication but is restricted to the ingress webserver.
+
+ :return: The Home Assistant system user.
+ """
+ username = HOMEASSISTANT_SYSTEM_USER
+ display_name = "Home Assistant Integration"
+ role = UserRole.USER
+
+ # Try to find existing user by username
+ user_row = await self.database.get_row("users", {"username": username})
+ if user_row:
+ # Use get_user to ensure preferences are parsed correctly
+ user = await self.get_user(user_row["user_id"])
+ assert user is not None # User exists in DB, so get_user must return it
+ return user
+
+ # Create new system user
+ user = await self.create_user(
+ username=username,
+ role=role,
+ display_name=display_name,
+ )
+ self.logger.debug("Created Home Assistant system user: %s (role: %s)", username, role.value)
+ return user
+
+ async def get_homeassistant_system_user_token(self) -> str:
+ """
+ Get or create an auth token for the Home Assistant system user.
+
+ This method ensures only one active token exists for the HA integration.
+ If an old token exists, it is deleted and a new one is created.
+ The token auto-renews on use (expires after 30 days of inactivity).
+
+ :return: Authentication token for the Home Assistant system user.
+ """
+ token_name = "Home Assistant Integration"
+
+ # Get the system user
+ system_user = await self.get_homeassistant_system_user()
+
+ # Delete any existing tokens with this name to avoid accumulation
+ # We can't retrieve the plain token from the hash, so we always create a new one
+ existing_tokens = await self.database.get_rows(
+ "auth_tokens",
+ {"user_id": system_user.user_id, "name": token_name},
+ )
+ for token_row in existing_tokens:
+ await self.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
+
+ # Create a new token for the system user
+ return await self.create_token(
+ user=system_user,
+ name=token_name,
+ is_long_lived=False,
+ )
+
+ async def link_user_to_provider(
+ self,
+ user: User,
+ provider_type: AuthProviderType,
+ provider_user_id: str,
+ ) -> UserAuthProvider:
+ """
+ Link a user to an authentication provider.
+
+ :param user: The user to link.
+ :param provider_type: The provider type.
+ :param provider_user_id: The user ID from the provider (e.g., password hash, OAuth ID).
+ """
+ link_id = secrets.token_urlsafe(32)
+ created_at = utc()
+ link_data = {
+ "link_id": link_id,
+ "user_id": user.user_id,
+ "provider_type": provider_type.value,
+ "provider_user_id": provider_user_id,
+ "created_at": created_at.isoformat(),
+ }
+
+ await self.database.insert("user_auth_providers", link_data)
+
+ return UserAuthProvider(
+ link_id=link_id,
+ user_id=user.user_id,
+ provider_type=provider_type,
+ provider_user_id=provider_user_id,
+ created_at=created_at,
+ )
+
+ async def update_user(
+ self,
+ user: User,
+ username: str | None = None,
+ display_name: str | None = None,
+ avatar_url: str | None = None,
+ ) -> User:
+ """
+ Update a user's profile information.
+
+ :param user: The user to update.
+ :param username: New username (optional).
+ :param display_name: New display name (optional).
+ :param avatar_url: New avatar URL (optional).
+ """
+ updates = {}
+ if username is not None:
+ updates["username"] = username
+ if display_name is not None:
+ updates["display_name"] = display_name
+ if avatar_url is not None:
+ updates["avatar_url"] = avatar_url
+
+ if updates:
+ await self.database.update("users", {"user_id": user.user_id}, updates)
+
+ # Return updated user
+ updated_user = await self.get_user(user.user_id)
+ assert updated_user is not None # User exists, so get_user must return it
+ return updated_user
+
+ async def update_user_preferences(
+ self,
+ user: User,
+ preferences: dict[str, Any],
+ ) -> User:
+ """
+ Update a user's preferences.
+
+ :param user: The user to update.
+ :param preferences: New preferences dict (completely replaces existing preferences).
+ """
+ # Verify user exists
+ current_user = await self.get_user(user.user_id)
+ if not current_user:
+ raise ValueError(f"User {user.user_id} not found")
+
+ # Update database with new preferences (complete replacement)
+ await self.database.update(
+ "users",
+ {"user_id": user.user_id},
+ {"preferences": json_dumps(preferences)},
+ )
+
+ # Return updated user
+ updated_user = await self.get_user(user.user_id)
+ assert updated_user is not None # User exists, so get_user must return it
+ return updated_user
+
+ async def update_provider_link(
+ self,
+ user: User,
+ provider_type: AuthProviderType,
+ provider_user_id: str,
+ ) -> None:
+ """
+ Update a user's provider link (e.g., change password).
+
+ :param user: The user.
+ :param provider_type: The provider type.
+ :param provider_user_id: The new provider user ID (e.g., new password hash).
+ """
+ # Find existing link
+ link_row = await self.database.get_row(
+ "user_auth_providers",
+ {
+ "user_id": user.user_id,
+ "provider_type": provider_type.value,
+ },
+ )
+
+ if link_row:
+ # Update existing link
+ await self.database.update(
+ "user_auth_providers",
+ {"link_id": link_row["link_id"]},
+ {"provider_user_id": provider_user_id},
+ )
+ else:
+ # Create new link
+ await self.link_user_to_provider(user, provider_type, provider_user_id)
+
+ async def create_token(self, user: User, name: str, is_long_lived: bool = False) -> str:
+ """
+ Create a new access token for a user.
+
+ :param user: The user to create the token for.
+ :param name: A name/description for the token (e.g., device name).
+ :param is_long_lived: Whether this is a long-lived token (default: False).
+ Short-lived tokens (False): Auto-renewing on use, expire after 30 days of inactivity.
+ Long-lived tokens (True): No auto-renewal, expire after 10 years.
+ """
+ # Generate token
+ token = secrets.token_urlsafe(48)
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+
+ # Calculate expiration based on token type
+ created_at = utc()
+ if is_long_lived:
+ # Long-lived tokens expire after 10 years (no auto-renewal)
+ expires_at = created_at + timedelta(days=TOKEN_LONG_LIVED_EXPIRATION)
+ else:
+ # Short-lived tokens expire after 30 days (with auto-renewal on use)
+ expires_at = created_at + timedelta(days=TOKEN_SHORT_LIVED_EXPIRATION)
+
+ # Store token
+ token_data = {
+ "token_id": secrets.token_urlsafe(32),
+ "user_id": user.user_id,
+ "token_hash": token_hash,
+ "name": name,
+ "created_at": created_at.isoformat(),
+ "expires_at": expires_at.isoformat(),
+ "is_long_lived": 1 if is_long_lived else 0,
+ }
+ await self.database.insert("auth_tokens", token_data)
+
+ return token
+
+ @api_command("auth/token/revoke")
+ async def revoke_token(self, token_id: str) -> None:
+ """
+ Revoke an auth token.
+
+ :param token_id: The token ID to revoke.
+ """
+ user = get_current_user()
+ if not user:
+ raise AuthenticationRequired("Not authenticated")
+
+ token_row = await self.database.get_row("auth_tokens", {"token_id": token_id})
+ if not token_row:
+ raise InvalidDataError("Token not found")
+
+ # Check permissions - users can only revoke their own tokens unless admin
+ if token_row["user_id"] != user.user_id and user.role != UserRole.ADMIN:
+ raise InsufficientPermissions("You can only revoke your own tokens")
+
+ await self.database.delete("auth_tokens", {"token_id": token_id})
+
+ # Disconnect any WebSocket connections using this token
+ self.webserver.disconnect_websockets_for_token(token_id)
+
+ @api_command("auth/tokens")
+ async def get_user_tokens(self, user_id: str | None = None) -> list[AuthToken]:
+ """
+ Get current user's auth tokens or another user's tokens (admin only).
+
+ :param user_id: Optional user ID to get tokens for (admin only).
+ :return: List of auth tokens.
+ """
+ current_user = get_current_user()
+ if not current_user:
+ return []
+
+ # If user_id is provided and different from current user, require admin
+ if user_id and user_id != current_user.user_id:
+ if current_user.role != UserRole.ADMIN:
+ return []
+ target_user = await self.get_user(user_id)
+ if not target_user:
+ return []
+ else:
+ target_user = current_user
+
+ token_rows = await self.database.get_rows(
+ "auth_tokens", {"user_id": target_user.user_id}, limit=100
+ )
+ return [AuthToken.from_dict(dict(row)) for row in token_rows]
+
+ @api_command("auth/users", required_role="admin")
+ async def list_users(self) -> list[User]:
+ """
+ Get all users (admin only).
+
+ System users are excluded from the list.
+
+ :return: List of user objects.
+ """
+ user_rows = await self.database.get_rows("users", limit=1000)
+ users = []
+ for row in user_rows:
+ row_dict = dict(row)
+
+ # Skip system users
+ if row_dict["username"] == HOMEASSISTANT_SYSTEM_USER:
+ continue
+
+ # Parse preferences
+ preferences = {}
+ if prefs_json := row_dict.get("preferences"):
+ try:
+ preferences = json_loads(prefs_json)
+ except Exception:
+ self.logger.warning(
+ "Failed to parse preferences for user %s", row_dict["user_id"]
+ )
+
+ users.append(
+ User(
+ user_id=row_dict["user_id"],
+ username=row_dict["username"],
+ role=UserRole(row_dict["role"]),
+ enabled=bool(row_dict["enabled"]),
+ created_at=datetime.fromisoformat(row_dict["created_at"]),
+ display_name=row_dict.get("display_name"),
+ avatar_url=row_dict.get("avatar_url"),
+ preferences=preferences,
+ )
+ )
+ return users
+
+ async def update_user_role(self, user_id: str, new_role: UserRole, admin_user: User) -> bool:
+ """
+ Update a user's role (admin only).
+
+ :param user_id: The user ID to update.
+ :param new_role: The new role to assign.
+ :param admin_user: The admin user performing the action.
+ """
+ if admin_user.role != UserRole.ADMIN:
+ return False
+
+ user_row = await self.database.get_row("users", {"user_id": user_id})
+ if not user_row:
+ return False
+
+ await self.database.update(
+ "users",
+ {"user_id": user_id},
+ {"role": new_role.value},
+ )
+ return True
+
+ @api_command("auth/user/enable", required_role="admin")
+ async def enable_user(self, user_id: str) -> None:
+ """
+ Enable user account (admin only).
+
+ :param user_id: The user ID.
+ """
+ await self.database.update(
+ "users",
+ {"user_id": user_id},
+ {"enabled": 1},
+ )
+
+ @api_command("auth/user/disable", required_role="admin")
+ async def disable_user(self, user_id: str) -> None:
+ """
+ Disable user account (admin only).
+
+ :param user_id: The user ID.
+ """
+ admin_user = get_current_user()
+ if not admin_user:
+ raise AuthenticationRequired("Not authenticated")
+
+ # Cannot disable yourself
+ if user_id == admin_user.user_id:
+ raise InvalidDataError("Cannot disable your own account")
+
+ await self.database.update(
+ "users",
+ {"user_id": user_id},
+ {"enabled": 0},
+ )
+
+ # Disconnect all WebSocket connections for this user
+ self.webserver.disconnect_websockets_for_user(user_id)
+
+ async def get_login_providers(self) -> list[dict[str, Any]]:
+ """Get list of available login providers (dynamically checks for HA provider)."""
+ # Sync HA OAuth provider with HA provider availability
+ await self._sync_ha_oauth_provider()
+
+ providers = []
+ for provider_id, provider in self.login_providers.items():
+ providers.append(
+ {
+ "provider_id": provider_id,
+ "provider_type": provider.provider_type.value,
+ "requires_redirect": provider.requires_redirect,
+ }
+ )
+ return providers
+
+ async def get_authorization_url(
+ self, provider_id: str, return_url: str | None = None
+ ) -> str | None:
+ """
+ Get OAuth authorization URL for a provider.
+
+ :param provider_id: The provider ID.
+ :param return_url: Optional URL to redirect to after successful login.
+ """
+ provider = self.login_providers.get(provider_id)
+ if not provider or not provider.requires_redirect:
+ return None
+
+ # Build callback redirect_uri
+ redirect_uri = f"{self.webserver.base_url}/auth/callback?provider_id={provider_id}"
+ return await provider.get_authorization_url(redirect_uri, return_url)
+
+ async def handle_oauth_callback(
+ self, provider_id: str, code: str, state: str, redirect_uri: str
+ ) -> AuthResult:
+ """
+ Handle OAuth callback.
+
+ :param provider_id: The provider ID.
+ :param code: OAuth authorization code.
+ :param state: OAuth state parameter.
+ :param redirect_uri: The callback URL.
+ """
+ provider = self.login_providers.get(provider_id)
+ if not provider:
+ return AuthResult(success=False, error="Invalid provider")
+
+ return await provider.handle_oauth_callback(code, state, redirect_uri)
+
+ @api_command("auth/token/create")
+ async def create_long_lived_token(self, name: str, user_id: str | None = None) -> str:
+ """
+ Create a new long-lived access token for current user or another user (admin only).
+
+ Long-lived tokens are intended for external integrations and API access.
+ They expire after 10 years and do NOT auto-renew on use.
+
+ Short-lived tokens (for regular user sessions) are only created during login
+ and auto-renew on each use (sliding 30-day expiration window).
+
+ :param name: The name/description for the token (e.g., "Home Assistant", "Mobile App").
+ :param user_id: Optional user ID to create token for (admin only).
+ :return: The created token string.
+ """
+ current_user = get_current_user()
+ if not current_user:
+ raise AuthenticationRequired("Not authenticated")
+
+ # If user_id is provided and different from current user, require admin
+ if user_id and user_id != current_user.user_id:
+ if current_user.role != UserRole.ADMIN:
+ raise InsufficientPermissions(
+ "Admin access required to create tokens for other users"
+ )
+ target_user = await self.get_user(user_id)
+ if not target_user:
+ raise InvalidDataError("User not found")
+ else:
+ target_user = current_user
+
+ # Create a long-lived token (only long-lived tokens can be created via this command)
+ token = await self.create_token(target_user, name, is_long_lived=True)
+ self.logger.info("Created long-lived token '%s' for user '%s'", name, target_user.username)
+ return token
+
+ @api_command("auth/user/create", required_role="admin")
+ async def create_user_with_api(
+ self,
+ username: str,
+ password: str,
+ role: str = "user",
+ display_name: str | None = None,
+ avatar_url: str | None = None,
+ ) -> User:
+ """
+ Create a new user with built-in authentication (admin only).
+
+ :param username: The username (minimum 3 characters).
+ :param password: The password (minimum 8 characters).
+ :param role: User role - "admin" or "user" (default: "user").
+ :param display_name: Optional display name.
+ :param avatar_url: Optional avatar URL.
+ :return: Created user object.
+ """
+ # Validation
+ if not username or len(username) < 3:
+ raise InvalidDataError("Username must be at least 3 characters")
+
+ if not password or len(password) < 8:
+ raise InvalidDataError("Password must be at least 8 characters")
+
+ # Validate role
+ try:
+ user_role = UserRole(role)
+ except ValueError as err:
+ raise InvalidDataError("Invalid role. Must be 'admin' or 'user'") from err
+
+ # Get built-in provider
+ builtin_provider = self.login_providers.get("builtin")
+ if not builtin_provider or not isinstance(builtin_provider, BuiltinLoginProvider):
+ raise InvalidDataError("Built-in auth provider not available")
+
+ # Create user with password
+ user = await builtin_provider.create_user_with_password(username, password, role=user_role)
+
+ # Update optional fields if provided
+ if display_name or avatar_url:
+ updated_user = await self.update_user(
+ user, display_name=display_name, avatar_url=avatar_url
+ )
+ if updated_user:
+ user = updated_user
+
+ self.logger.info("User created by admin: %s (role: %s)", username, role)
+ return user
+
+ @api_command("auth/user/delete", required_role="admin")
+ async def delete_user(self, user_id: str) -> None:
+ """
+ Delete user account (admin only).
+
+ :param user_id: The user ID.
+ """
+ admin_user = get_current_user()
+ if not admin_user:
+ raise AuthenticationRequired("Not authenticated")
+
+ # Don't allow deleting yourself
+ if user_id == admin_user.user_id:
+ raise InvalidDataError("Cannot delete your own account")
+
+ # Delete user from database
+ await self.database.delete("users", {"user_id": user_id})
+ await self.database.commit()
+
+ # Disconnect all WebSocket connections for this user
+ self.webserver.disconnect_websockets_for_user(user_id)
+
+ @api_command("auth/me")
+ async def get_current_user_info(self) -> User:
+ """Get current authenticated user information."""
+ current_user_obj = get_current_user()
+ if not current_user_obj:
+ raise AuthenticationRequired("Not authenticated")
+ return current_user_obj
+
+ async def _update_profile_password(
+ self,
+ target_user: User,
+ password: str,
+ old_password: str | None,
+ is_admin_update: bool,
+ current_user: User,
+ ) -> None:
+ """Update user password (helper method)."""
+ if len(password) < 8:
+ raise InvalidDataError("Password must be at least 8 characters")
+
+ builtin_provider = self.login_providers.get("builtin")
+ if not builtin_provider or not isinstance(builtin_provider, BuiltinLoginProvider):
+ raise InvalidDataError("Built-in auth not available")
+
+ if is_admin_update:
+ # Admin can reset password without old password
+ await builtin_provider.reset_password(target_user, password)
+ self.logger.info(
+ "Password reset for user %s by admin %s",
+ target_user.username,
+ current_user.username,
+ )
+ else:
+ # User updating own password - requires old password verification
+ if not old_password:
+ raise InvalidDataError("old_password is required to change your own password")
+
+ # Verify old password and change to new one
+ success = await builtin_provider.change_password(target_user, old_password, password)
+ if not success:
+ raise AuthenticationFailed("Invalid current password")
+
+ self.logger.info("Password changed for user %s", target_user.username)
+
+ @api_command("auth/user/update")
+ async def update_user_profile(
+ self,
+ user_id: str | None = None,
+ username: str | None = None,
+ display_name: str | None = None,
+ avatar_url: str | None = None,
+ password: str | None = None,
+ old_password: str | None = None,
+ role: str | None = None,
+ preferences: dict[str, Any] | None = None,
+ ) -> User:
+ """
+ Update user profile information.
+
+ Users can update their own profile. Admins can update any user including role and password.
+
+ :param user_id: User ID to update (optional, defaults to current user).
+ :param username: New username (optional).
+ :param display_name: New display name (optional).
+ :param avatar_url: New avatar URL (optional).
+ :param password: New password (optional, minimum 8 characters).
+ :param old_password: Current password (required when user updates own password).
+ :param role: New role - "admin" or "user" (optional, admin only).
+ :param preferences: User preferences dict (completely replaces existing, optional).
+ :return: Updated user object.
+ """
+ current_user_obj = get_current_user()
+ if not current_user_obj:
+ raise AuthenticationRequired("Not authenticated")
+
+ # Determine target user
+ if user_id and user_id != current_user_obj.user_id:
+ # Updating another user - requires admin
+ if current_user_obj.role != UserRole.ADMIN:
+ raise InsufficientPermissions("Admin access required")
+ target_user = await self.get_user(user_id)
+ if not target_user:
+ raise InvalidDataError("User not found")
+ is_admin_update = True
+ else:
+ # Updating own profile
+ target_user = current_user_obj
+ is_admin_update = False
+
+ # Update role (admin only)
+ if role:
+ if not is_admin_update:
+ raise InsufficientPermissions("Only admins can update user roles")
+
+ try:
+ new_role = UserRole(role)
+ except ValueError as err:
+ raise InvalidDataError("Invalid role. Must be 'admin' or 'user'") from err
+
+ success = await self.update_user_role(target_user.user_id, new_role, current_user_obj)
+ if not success:
+ raise InvalidDataError("Failed to update role")
+
+ # Refresh target user to get updated role
+ refreshed_user = await self.get_user(target_user.user_id)
+ if not refreshed_user:
+ raise InvalidDataError("Failed to refresh user after role update")
+ target_user = refreshed_user
+
+ # Update basic profile fields
+ if username or display_name or avatar_url:
+ updated_user = await self.update_user(
+ target_user,
+ username=username,
+ display_name=display_name,
+ avatar_url=avatar_url,
+ )
+ if not updated_user:
+ raise InvalidDataError("Failed to update user profile")
+ target_user = updated_user
+
+ # Update preferences if provided
+ if preferences is not None:
+ target_user = await self.update_user_preferences(target_user, preferences)
+
+ # Update password if provided
+ if password:
+ await self._update_profile_password(
+ target_user, password, old_password, is_admin_update, current_user_obj
+ )
+
+ return target_user
+
+ @api_command("auth/logout")
+ async def logout(self) -> None:
+ """Logout current user by revoking the current token."""
+ user = get_current_user()
+ if not user:
+ raise AuthenticationRequired("Not authenticated")
+
+ # Get current token from context
+ token = get_current_token()
+ if not token:
+ raise InvalidDataError("No token in context")
+
+ # Find and revoke the token
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_row = await self.database.get_row("auth_tokens", {"token_hash": token_hash})
+ if token_row:
+ await self.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
+
+ # Disconnect any WebSocket connections using this token
+ self.webserver.disconnect_websockets_for_token(token_row["token_id"])
+
+ @api_command("auth/user/providers")
+ async def get_my_providers(self) -> list[dict[str, Any]]:
+ """
+ Get current user's linked authentication providers.
+
+ :return: List of provider links.
+ """
+ user = get_current_user()
+ if not user:
+ return []
+
+ # Get provider links from database
+ rows = await self.database.get_rows("user_auth_providers", {"user_id": user.user_id})
+ providers = [UserAuthProvider.from_dict(dict(row)) for row in rows]
+ return [p.to_dict() for p in providers]
+
+ @api_command("auth/user/unlink_provider", required_role="admin")
+ async def unlink_provider(self, user_id: str, provider_type: str) -> bool:
+ """
+ Unlink authentication provider from user (admin only).
+
+ :param user_id: The user ID.
+ :param provider_type: Provider type to unlink.
+ :return: True if successful.
+ """
+ await self.database.delete(
+ "user_auth_providers", {"user_id": user_id, "provider_type": provider_type}
+ )
+ await self.database.commit()
+ return True
--- /dev/null
+"""
+Controller that manages the builtin webserver that hosts the api and frontend.
+
+Unlike the streamserver (which is as simple and unprotected as possible),
+this webserver allows for more fine grained configuration to better secure it.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import html
+import json
+import os
+import urllib.parse
+from collections.abc import Awaitable, Callable
+from concurrent import futures
+from functools import partial
+from typing import TYPE_CHECKING, Any, Final, cast
+from urllib.parse import quote
+
+import aiofiles
+from aiohttp import ClientTimeout, web
+from mashumaro.exceptions import MissingField
+from music_assistant_frontend import where as locate_frontend
+from music_assistant_models.api import CommandMessage
+from music_assistant_models.auth import AuthProviderType, User, UserRole
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
+from music_assistant_models.enums import ConfigEntryType
+
+from music_assistant.constants import (
+ CONF_AUTH_ALLOW_SELF_REGISTRATION,
+ CONF_BIND_IP,
+ CONF_BIND_PORT,
+ CONF_ONBOARD_DONE,
+ RESOURCES_DIR,
+ VERBOSE_LOG_LEVEL,
+)
+from music_assistant.helpers.api import parse_arguments
+from music_assistant.helpers.audio import get_preview_stream
+from music_assistant.helpers.json import json_dumps, json_loads
+from music_assistant.helpers.redirect_validation import is_allowed_redirect_url
+from music_assistant.helpers.util import get_ip_addresses
+from music_assistant.helpers.webserver import Webserver
+from music_assistant.models.core_controller import CoreController
+
+from .api_docs import generate_commands_json, generate_openapi_spec, generate_schemas_json
+from .auth import AuthenticationManager
+from .helpers.auth_middleware import (
+ get_authenticated_user,
+ is_request_from_ingress,
+ set_current_user,
+)
+from .helpers.auth_providers import BuiltinLoginProvider
+from .websocket_client import WebsocketClientHandler
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ConfigValueType, CoreConfig
+
+ from music_assistant import MusicAssistant
+
+DEFAULT_SERVER_PORT = 8095
+INGRESS_SERVER_PORT = 8094
+CONF_BASE_URL = "base_url"
+MAX_PENDING_MSG = 512
+CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
+
+
+class WebserverController(CoreController):
+ """Core Controller that manages the builtin webserver that hosts the api and frontend."""
+
+ domain: str = "webserver"
+
+ def __init__(self, mass: MusicAssistant) -> None:
+ """Initialize instance."""
+ super().__init__(mass)
+ self._server = Webserver(self.logger, enable_dynamic_routes=True)
+ self.register_dynamic_route = self._server.register_dynamic_route
+ self.unregister_dynamic_route = self._server.unregister_dynamic_route
+ self.clients: set[WebsocketClientHandler] = set()
+ self.manifest.name = "Web Server (frontend and api)"
+ self.manifest.description = (
+ "The built-in webserver that hosts the Music Assistant Websockets API and frontend"
+ )
+ self.manifest.icon = "web-box"
+ self.auth = AuthenticationManager(self)
+
+ @property
+ def base_url(self) -> str:
+ """Return the base_url for the streamserver."""
+ return self._server.base_url
+
+ async def get_config_entries(
+ self,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+ ) -> tuple[ConfigEntry, ...]:
+ """Return all Config Entries for this core module (if any)."""
+ ip_addresses = await get_ip_addresses()
+ default_publish_ip = ip_addresses[0]
+ default_base_url = f"http://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
+ return (
+ ConfigEntry(
+ key="webserver_warn",
+ type=ConfigEntryType.ALERT,
+ label="Please note that the webserver is unprotected. "
+ "Never ever expose the webserver directly to the internet! \n\n"
+ "Use a reverse proxy or VPN to secure access.",
+ required=False,
+ ),
+ ConfigEntry(
+ key=CONF_BASE_URL,
+ type=ConfigEntryType.STRING,
+ default_value=default_base_url,
+ label="Base URL",
+ description="The (base) URL to reach this webserver in the network. \n"
+ "Override this in advanced scenarios where for example you're running "
+ "the webserver behind a reverse proxy.",
+ ),
+ ConfigEntry(
+ key=CONF_BIND_PORT,
+ type=ConfigEntryType.INTEGER,
+ default_value=DEFAULT_SERVER_PORT,
+ label="TCP Port",
+ description="The TCP port to run the webserver.",
+ ),
+ ConfigEntry(
+ key=CONF_BIND_IP,
+ type=ConfigEntryType.STRING,
+ default_value="0.0.0.0",
+ options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
+ label="Bind to IP/interface",
+ description="Bind the (web)server to this specific interface. \n"
+ "Use 0.0.0.0 to bind to all interfaces. \n"
+ "Set this address for example to a docker-internal network, "
+ "when you are running a reverse proxy to enhance security and "
+ "protect outside access to the webinterface and API. \n\n"
+ "This is an advanced setting that should normally "
+ "not be adjusted in regular setups.",
+ category="advanced",
+ ),
+ ConfigEntry(
+ key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
+ type=ConfigEntryType.BOOLEAN,
+ default_value=True,
+ label="Allow Self-Registration",
+ description="Allow users to create accounts via Home Assistant OAuth. \n"
+ "New users will have USER role by default.",
+ category="advanced",
+ hidden=not any(provider.domain == "hass" for provider in self.mass.providers),
+ ),
+ )
+
+ async def setup(self, config: CoreConfig) -> None: # noqa: PLR0915
+ """Async initialize of module."""
+ self.config = config
+ # work out all routes
+ routes: list[tuple[str, str, Callable[[web.Request], Awaitable[web.StreamResponse]]]] = []
+ # frontend routes
+ frontend_dir = locate_frontend()
+ for filename in next(os.walk(frontend_dir))[2]:
+ if filename.endswith(".py"):
+ continue
+ filepath = os.path.join(frontend_dir, filename)
+ handler = partial(self._server.serve_static, filepath)
+ routes.append(("GET", f"/{filename}", handler))
+ # add index
+ index_path = os.path.join(frontend_dir, "index.html")
+ handler = partial(self._server.serve_static, index_path)
+ routes.append(("GET", "/", handler))
+ # add logo
+ logo_path = str(RESOURCES_DIR.joinpath("logo.png"))
+ handler = partial(self._server.serve_static, logo_path)
+ routes.append(("GET", "/logo.png", handler))
+ # add common CSS for HTML resources
+ common_css_path = str(RESOURCES_DIR.joinpath("common.css"))
+ handler = partial(self._server.serve_static, common_css_path)
+ routes.append(("GET", "/resources/common.css", handler))
+ # add info
+ routes.append(("GET", "/info", self._handle_server_info))
+ routes.append(("OPTIONS", "/info", self._handle_cors_preflight))
+ # add logging
+ routes.append(("GET", "/music-assistant.log", self._handle_application_log))
+ # add websocket api
+ routes.append(("GET", "/ws", self._handle_ws_client))
+ # also host the image proxy on the webserver
+ routes.append(("GET", "/imageproxy", self.mass.metadata.handle_imageproxy))
+ # also host the audio preview service
+ routes.append(("GET", "/preview", self.serve_preview_stream))
+ # add jsonrpc api
+ routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
+ # add api documentation
+ routes.append(("GET", "/api-docs", self._handle_api_intro))
+ routes.append(("GET", "/api-docs/", self._handle_api_intro))
+ routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
+ routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
+ routes.append(("GET", "/api-docs/commands.json", self._handle_commands_json))
+ routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
+ routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
+ routes.append(("GET", "/api-docs/schemas.json", self._handle_schemas_json))
+ routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
+ routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
+ routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
+ # add authentication routes
+ routes.append(("GET", "/login", self._handle_login_page))
+ routes.append(("POST", "/auth/login", self._handle_auth_login))
+ routes.append(("OPTIONS", "/auth/login", self._handle_cors_preflight))
+ routes.append(("POST", "/auth/logout", self._handle_auth_logout))
+ routes.append(("GET", "/auth/me", self._handle_auth_me))
+ routes.append(("PATCH", "/auth/me", self._handle_auth_me_update))
+ routes.append(("GET", "/auth/providers", self._handle_auth_providers))
+ routes.append(("GET", "/auth/authorize", self._handle_auth_authorize))
+ routes.append(("GET", "/auth/callback", self._handle_auth_callback))
+ # add first-time setup routes
+ routes.append(("GET", "/setup", self._handle_setup_page))
+ routes.append(("POST", "/setup", self._handle_setup))
+ # Initialize authentication manager
+ await self.auth.setup()
+ # start the webserver
+ all_ip_addresses = await get_ip_addresses()
+ default_publish_ip = all_ip_addresses[0]
+ if self.mass.running_as_hass_addon:
+ # if we're running on the HA supervisor we start an additional TCP site
+ # on the internal ("172.30.32.) IP for the HA ingress proxy
+ ingress_host = next(
+ (x for x in all_ip_addresses if x.startswith("172.30.32.")), default_publish_ip
+ )
+ ingress_tcp_site_params = (ingress_host, INGRESS_SERVER_PORT)
+ else:
+ ingress_tcp_site_params = None
+ base_url = str(config.get_value(CONF_BASE_URL))
+ port_value = config.get_value(CONF_BIND_PORT)
+ assert isinstance(port_value, int)
+ self.publish_port = port_value
+ self.publish_ip = default_publish_ip
+ bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
+ # print a big fat message in the log where the webserver is running
+ # because this is a common source of issues for people with more complex setups
+ if not self.mass.config.onboard_done:
+ self.logger.warning(
+ "\n\n################################################################################\n"
+ "Starting webserver on %s:%s - base url: %s\n"
+ "If this is incorrect, see the documentation how to configure the Webserver\n"
+ "in Settings --> Core modules --> Webserver\n"
+ "################################################################################\n",
+ bind_ip,
+ self.publish_port,
+ base_url,
+ )
+ else:
+ self.logger.info(
+ "Starting webserver on %s:%s - base url: %s\n#\n",
+ bind_ip,
+ self.publish_port,
+ base_url,
+ )
+ await self._server.setup(
+ bind_ip=bind_ip,
+ bind_port=self.publish_port,
+ base_url=base_url,
+ static_routes=routes,
+ # add assets subdir as static_content
+ static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"),
+ ingress_tcp_site_params=ingress_tcp_site_params,
+ # Add mass object to app for use in auth middleware
+ app_state={"mass": self.mass},
+ )
+ if self.mass.running_as_hass_addon:
+ # announce to HA supervisor
+ await self._announce_to_homeassistant()
+
+ async def close(self) -> None:
+ """Cleanup on exit."""
+ for client in set(self.clients):
+ await client.disconnect()
+ await self._server.close()
+ await self.auth.close()
+
+ def register_websocket_client(self, client: WebsocketClientHandler) -> None:
+ """Register a WebSocket client for tracking."""
+ self.clients.add(client)
+
+ def unregister_websocket_client(self, client: WebsocketClientHandler) -> None:
+ """Unregister a WebSocket client."""
+ self.clients.discard(client)
+
+ def disconnect_websockets_for_token(self, token_id: str) -> None:
+ """Disconnect all WebSocket clients using a specific token."""
+ for client in list(self.clients):
+ if hasattr(client, "_token_id") and client._token_id == token_id:
+ username = (
+ client._authenticated_user.username if client._authenticated_user else "unknown"
+ )
+ self.logger.warning(
+ "Disconnecting WebSocket client due to token revocation: %s",
+ username,
+ )
+ client._cancel()
+
+ def disconnect_websockets_for_user(self, user_id: str) -> None:
+ """Disconnect all WebSocket clients for a specific user."""
+ for client in list(self.clients):
+ if (
+ hasattr(client, "_authenticated_user")
+ and client._authenticated_user
+ and client._authenticated_user.user_id == user_id
+ ):
+ self.logger.warning(
+ "Disconnecting WebSocket client due to user action: %s",
+ client._authenticated_user.username,
+ )
+ client._cancel()
+
+ async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
+ """Serve short preview sample."""
+ provider_instance_id_or_domain = request.query["provider"]
+ item_id = urllib.parse.unquote(request.query["item_id"])
+ resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/aac"})
+ await resp.prepare(request)
+ async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
+ await resp.write(chunk)
+ return resp
+
+ async def _handle_cors_preflight(self, request: web.Request) -> web.Response:
+ """Handle CORS preflight OPTIONS request."""
+ return web.Response(
+ status=200,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Max-Age": "86400", # Cache preflight for 24 hours
+ },
+ )
+
+ async def _handle_server_info(self, request: web.Request) -> web.Response:
+ """Handle request for server info."""
+ server_info = self.mass.get_server_info()
+ # Add CORS headers to allow frontend to call from any origin
+ return web.json_response(
+ server_info.to_dict(),
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ )
+
+ async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
+ connection = WebsocketClientHandler(self, request)
+ if lang := request.headers.get("Accept-Language"):
+ self.mass.metadata.set_default_preferred_language(lang.split(",")[0])
+ try:
+ self.clients.add(connection)
+ return await connection.handle_client()
+ finally:
+ self.clients.discard(connection)
+
+ async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
+ """Handle incoming JSON RPC API command."""
+ if not request.can_read_body:
+ return web.Response(status=400, text="Body required")
+ cmd_data = await request.read()
+ self.logger.log(VERBOSE_LOG_LEVEL, "Received on JSONRPC API: %s", cmd_data)
+ try:
+ command_msg = CommandMessage.from_json(cmd_data)
+ except ValueError:
+ error = f"Invalid JSON: {cmd_data.decode()}"
+ self.logger.error("Unhandled JSONRPC API error: %s", error)
+ return web.Response(status=400, text=error)
+ except MissingField as e:
+ # be forgiving if message_id is missing
+ cmd_data_dict = json_loads(cmd_data)
+ if e.field_name == "message_id" and "command" in cmd_data_dict:
+ cmd_data_dict["message_id"] = "unknown"
+ command_msg = CommandMessage.from_dict(cmd_data_dict)
+ else:
+ error = f"Missing field in JSON: {e.field_name}"
+ self.logger.error("Unhandled JSONRPC API error: %s", error)
+ return web.Response(status=400, text="Invalid JSON: missing required field")
+
+ # work out handler for the given path/command
+ handler = self.mass.command_handlers.get(command_msg.command)
+ if handler is None:
+ error = f"Invalid Command: {command_msg.command}"
+ self.logger.error("Unhandled JSONRPC API error: %s", error)
+ return web.Response(status=400, text=error)
+
+ # Check authentication if required
+ if handler.authenticated or handler.required_role:
+ if is_request_from_ingress(request):
+ # Ingress authentication (Home Assistant)
+ user = await self._get_ingress_user(request)
+ if not user:
+ # This should not happen - ingress requests should have user headers
+ return web.Response(
+ status=401,
+ text="Ingress authentication failed - missing user information",
+ )
+ else:
+ # Regular authentication (non-ingress)
+ try:
+ user = await get_authenticated_user(request)
+ except Exception as e:
+ self.logger.exception("Authentication error: %s", e)
+ return web.Response(
+ status=401,
+ text="Authentication failed",
+ headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
+ )
+
+ if not user:
+ return web.Response(
+ status=401,
+ text="Authentication required",
+ headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
+ )
+
+ # Set user in context and check role
+ set_current_user(user)
+ if handler.required_role == "admin" and user.role != UserRole.ADMIN:
+ return web.Response(
+ status=403,
+ text="Admin access required",
+ )
+
+ try:
+ args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
+ result: Any = handler.target(**args)
+ if hasattr(result, "__anext__"):
+ # handle async generator (for really large listings)
+ result = [item async for item in result]
+ elif asyncio.iscoroutine(result):
+ result = await result
+ return web.json_response(result, dumps=json_dumps)
+ except Exception as e:
+ # Return clean error message without stacktrace
+ error_type = type(e).__name__
+ error_msg = str(e)
+ error = f"{error_type}: {error_msg}"
+ self.logger.exception("Error executing command %s: %s", command_msg.command, error)
+ return web.Response(status=500, text="Internal server error")
+
+ async def _handle_application_log(self, request: web.Request) -> web.Response:
+ """Handle request to get the application log."""
+ log_data = await self.mass.get_application_log()
+ return web.Response(text=log_data, content_type="text/text")
+
+ async def _handle_api_intro(self, request: web.Request) -> web.Response:
+ """Handle request for API introduction/documentation page."""
+ intro_html_path = str(RESOURCES_DIR.joinpath("api_docs.html"))
+ # Read the template
+ async with aiofiles.open(intro_html_path) as f:
+ html_content = await f.read()
+
+ # Replace placeholders (escape values to prevent XSS)
+ html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
+ html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
+ html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
+
+ return web.Response(text=html_content, content_type="text/html")
+
+ async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
+ """Handle request for OpenAPI specification (generated on-the-fly)."""
+ spec = generate_openapi_spec(
+ self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
+ )
+ return web.json_response(spec)
+
+ async def _handle_commands_reference(self, request: web.Request) -> web.FileResponse:
+ """Handle request for commands reference page."""
+ commands_html_path = str(RESOURCES_DIR.joinpath("commands_reference.html"))
+ return await self._server.serve_static(commands_html_path, request)
+
+ async def _handle_commands_json(self, request: web.Request) -> web.Response:
+ """Handle request for commands JSON data (generated on-the-fly)."""
+ commands_data = generate_commands_json(self.mass.command_handlers)
+ return web.json_response(commands_data)
+
+ async def _handle_schemas_reference(self, request: web.Request) -> web.FileResponse:
+ """Handle request for schemas reference page."""
+ schemas_html_path = str(RESOURCES_DIR.joinpath("schemas_reference.html"))
+ return await self._server.serve_static(schemas_html_path, request)
+
+ async def _handle_schemas_json(self, request: web.Request) -> web.Response:
+ """Handle request for schemas JSON data (generated on-the-fly)."""
+ schemas_data = generate_schemas_json(self.mass.command_handlers)
+ return web.json_response(schemas_data)
+
+ async def _handle_swagger_ui(self, request: web.Request) -> web.FileResponse:
+ """Handle request for Swagger UI."""
+ swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html"))
+ return await self._server.serve_static(swagger_html_path, request)
+
+ async def _handle_login_page(self, request: web.Request) -> web.Response:
+ """Handle request for login page."""
+ # If not yet onboarded, redirect to setup
+ if not self.mass.config.onboard_done or not await self.auth.has_users():
+ return_url = request.query.get("return_url", "")
+ device_name = request.query.get("device_name", "")
+ setup_url = (
+ f"/setup?return_url={return_url}&device_name={device_name}"
+ if return_url
+ else "/setup"
+ )
+ return web.Response(status=302, headers={"Location": setup_url})
+
+ # Check if this is an ingress request - if so, auto-authenticate and redirect with token
+ if is_request_from_ingress(request):
+ ingress_user_id = request.headers.get("X-Remote-User-ID")
+ ingress_username = request.headers.get("X-Remote-User-Name")
+
+ if ingress_user_id and ingress_username:
+ # Try to find existing user linked to this HA user ID
+ user = await self.auth.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ if user:
+ # User exists, create token and redirect
+ device_name = request.query.get(
+ "device_name", f"Home Assistant Ingress ({ingress_username})"
+ )
+ token = await self.auth.create_token(user, device_name)
+
+ return_url = request.query.get("return_url", "/")
+
+ # Insert code parameter before any hash fragment
+ code_param = f"code={quote(token, safe='')}"
+ if "#" in return_url:
+ url_parts = return_url.split("#", 1)
+ base_part = url_parts[0]
+ hash_part = url_parts[1]
+ separator = "&" if "?" in base_part else "?"
+ redirect_url = f"{base_part}{separator}{code_param}#{hash_part}"
+ elif "?" in return_url:
+ redirect_url = f"{return_url}&{code_param}"
+ else:
+ redirect_url = f"{return_url}?{code_param}"
+
+ return web.Response(status=302, headers={"Location": redirect_url})
+
+ # Not ingress or user doesn't exist - serve login page
+ login_html_path = str(RESOURCES_DIR.joinpath("login.html"))
+ async with aiofiles.open(login_html_path) as f:
+ html_content = await f.read()
+ return web.Response(text=html_content, content_type="text/html")
+
+ async def _handle_auth_login(self, request: web.Request) -> web.Response:
+ """Handle login request."""
+ try:
+ if not request.can_read_body:
+ return web.Response(status=400, text="Body required")
+
+ body = await request.json()
+ provider_id = body.get("provider_id", "builtin") # Default to built-in provider
+ credentials = body.get("credentials", {})
+ return_url = body.get("return_url") # Optional return URL for redirect after login
+
+ # Authenticate with provider
+ auth_result = await self.auth.authenticate_with_credentials(provider_id, credentials)
+
+ if not auth_result.success or not auth_result.user:
+ return web.json_response(
+ {"success": False, "error": auth_result.error},
+ status=401,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ )
+
+ # Create token for user
+ device_name = body.get(
+ "device_name", f"{request.headers.get('User-Agent', 'Unknown')[:50]}"
+ )
+ token = await self.auth.create_token(auth_result.user, device_name)
+
+ # Prepare response data
+ response_data = {
+ "success": True,
+ "token": token,
+ "user": auth_result.user.to_dict(),
+ }
+
+ # If return_url provided, append code parameter and return as redirect_to
+ if return_url:
+ # Insert code parameter before any hash fragment
+ code_param = f"code={quote(token, safe='')}"
+ if "#" in return_url:
+ url_parts = return_url.split("#", 1)
+ base_part = url_parts[0]
+ hash_part = url_parts[1]
+ separator = "&" if "?" in base_part else "?"
+ redirect_url = f"{base_part}{separator}{code_param}#{hash_part}"
+ elif "?" in return_url:
+ redirect_url = f"{return_url}&{code_param}"
+ else:
+ redirect_url = f"{return_url}?{code_param}"
+
+ response_data["redirect_to"] = redirect_url
+ self.logger.debug(
+ "Login successful, returning redirect_to: %s",
+ redirect_url.replace(token, "***TOKEN***"),
+ )
+
+ # Add CORS headers to allow login from any origin
+ return web.json_response(
+ response_data,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ )
+ except Exception:
+ self.logger.exception("Error during login")
+ return web.json_response(
+ {"success": False, "error": "Login failed"},
+ status=500,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ )
+
+ async def _handle_auth_logout(self, request: web.Request) -> web.Response:
+ """Handle logout request."""
+ user = await get_authenticated_user(request)
+ if not user:
+ return web.Response(status=401, text="Not authenticated")
+
+ # Get token from request
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ # Find and revoke the token
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_row = await self.auth.database.get_row("auth_tokens", {"token_hash": token_hash})
+ if token_row:
+ await self.auth.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
+
+ return web.json_response({"success": True})
+
+ async def _handle_auth_me(self, request: web.Request) -> web.Response:
+ """Handle request for current user information."""
+ user = await get_authenticated_user(request)
+ if not user:
+ return web.Response(status=401, text="Not authenticated")
+
+ return web.json_response(user.to_dict())
+
+ async def _handle_auth_me_update(self, request: web.Request) -> web.Response:
+ """Handle request to update current user's profile."""
+ user = await get_authenticated_user(request)
+ if not user:
+ return web.Response(status=401, text="Not authenticated")
+
+ try:
+ if not request.can_read_body:
+ return web.Response(status=400, text="Body required")
+
+ body = await request.json()
+ username = body.get("username")
+ display_name = body.get("display_name")
+ avatar_url = body.get("avatar_url")
+
+ # Update user
+ updated_user = await self.auth.update_user(
+ user,
+ username=username,
+ display_name=display_name,
+ avatar_url=avatar_url,
+ )
+
+ return web.json_response({"success": True, "user": updated_user.to_dict()})
+ except Exception:
+ self.logger.exception("Error updating user profile")
+ return web.json_response(
+ {"success": False, "error": "Failed to update profile"}, status=500
+ )
+
+ async def _handle_auth_providers(self, request: web.Request) -> web.Response:
+ """Handle request for available login providers."""
+ try:
+ providers = await self.auth.get_login_providers()
+ return web.json_response(providers)
+ except Exception:
+ self.logger.exception("Error getting auth providers")
+ return web.json_response({"error": "Failed to get auth providers"}, status=500)
+
+ async def _handle_auth_authorize(self, request: web.Request) -> web.Response:
+ """Handle OAuth authorization request."""
+ try:
+ provider_id = request.query.get("provider_id")
+ return_url = request.query.get("return_url")
+
+ self.logger.debug(
+ "OAuth authorize request: provider_id=%s, return_url=%s", provider_id, return_url
+ )
+
+ if not provider_id:
+ return web.Response(status=400, text="provider_id required")
+
+ # Validate return_url if provided
+ if return_url:
+ is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
+ if not is_valid:
+ return web.Response(status=400, text="Invalid return_url")
+
+ auth_url = await self.auth.get_authorization_url(provider_id, return_url)
+ if not auth_url:
+ return web.Response(
+ status=400, text="Provider does not support OAuth or is not configured"
+ )
+
+ return web.json_response({"authorization_url": auth_url})
+ except Exception:
+ self.logger.exception("Error during OAuth authorization")
+ return web.json_response({"error": "Authorization failed"}, status=500)
+
+ async def _handle_auth_callback(self, request: web.Request) -> web.Response:
+ """Handle OAuth callback."""
+ try:
+ code = request.query.get("code")
+ state = request.query.get("state")
+ provider_id = request.query.get("provider_id")
+
+ if not code or not state or not provider_id:
+ return web.Response(status=400, text="code, state, and provider_id required")
+
+ redirect_uri = f"{self.base_url}/auth/callback?provider_id={provider_id}"
+ auth_result = await self.auth.handle_oauth_callback(
+ provider_id, code, state, redirect_uri
+ )
+
+ if not auth_result.success or not auth_result.user:
+ # Return error page
+ error_html = f"""
+ <html>
+ <body>
+ <h1>Authentication Failed</h1>
+ <p>{html.escape(auth_result.error or "Unknown error")}</p>
+ <a href="/login">Back to Login</a>
+ </body>
+ </html>
+ """
+ return web.Response(text=error_html, content_type="text/html", status=400)
+
+ # Create token
+ device_name = f"OAuth ({provider_id})"
+ token = await self.auth.create_token(auth_result.user, device_name)
+
+ # Determine redirect URL (use return_url from OAuth flow or default to root)
+ final_redirect_url = auth_result.return_url or "/"
+ requires_consent = False
+
+ # Validate redirect URL for security
+ if auth_result.return_url:
+ is_valid, category = is_allowed_redirect_url(
+ auth_result.return_url, request, self.base_url
+ )
+ if not is_valid:
+ self.logger.warning("Invalid return_url blocked: %s", auth_result.return_url)
+ final_redirect_url = "/"
+ elif category == "external":
+ # External domain - require user consent
+ requires_consent = True
+ # Add code parameter to redirect URL (the token URL-encoded)
+ # Important: Insert code BEFORE any hash fragment (e.g., #/) to ensure
+ # it's in query params, not inside the hash where Vue Router can't access it
+ code_param = f"code={quote(token, safe='')}"
+
+ # Split URL by hash to insert code in the right place
+ if "#" in final_redirect_url:
+ # URL has a hash fragment (e.g., http://example.com/#/ or http://example.com/path#section)
+ url_parts = final_redirect_url.split("#", 1)
+ base_url = url_parts[0]
+ hash_part = url_parts[1]
+
+ # Add code to base URL (before hash)
+ separator = "&" if "?" in base_url else "?"
+ final_redirect_url = f"{base_url}{separator}{code_param}#{hash_part}"
+ # No hash fragment, simple case
+ elif "?" in final_redirect_url:
+ final_redirect_url = f"{final_redirect_url}&{code_param}"
+ else:
+ final_redirect_url = f"{final_redirect_url}?{code_param}"
+
+ # Load OAuth callback success page template and inject token and redirect URL
+ oauth_callback_html_path = str(RESOURCES_DIR.joinpath("oauth_callback.html"))
+ async with aiofiles.open(oauth_callback_html_path) as f:
+ success_html = await f.read()
+
+ # Replace template placeholders
+ success_html = success_html.replace("{TOKEN}", token)
+ success_html = success_html.replace("{REDIRECT_URL}", final_redirect_url)
+ success_html = success_html.replace(
+ "{REQUIRES_CONSENT}", "true" if requires_consent else "false"
+ )
+
+ return web.Response(text=success_html, content_type="text/html")
+ except Exception:
+ self.logger.exception("Error during OAuth callback")
+ error_html = """
+ <html>
+ <body>
+ <h1>Authentication Failed</h1>
+ <p>An error occurred during authentication</p>
+ <a href="/login">Back to Login</a>
+ </body>
+ </html>
+ """
+ return web.Response(text=error_html, content_type="text/html", status=500)
+
+ async def _handle_setup_page(self, request: web.Request) -> web.Response:
+ """Handle request for first-time setup page."""
+ # Check if setup is needed
+ # Allow setup if either:
+ # 1. No users exist yet (fresh install)
+ # 2. Users exist but onboarding not done (e.g., Ingress auto-created user)
+ if await self.auth.has_users() and self.mass.config.get(CONF_ONBOARD_DONE):
+ # Setup already completed, redirect to login
+ return web.Response(status=302, headers={"Location": "/login"})
+
+ # Validate return_url if provided
+ return_url = request.query.get("return_url")
+ if return_url:
+ is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
+ if not is_valid:
+ return web.Response(status=400, text="Invalid return_url")
+
+ # Serve setup page
+ setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
+ async with aiofiles.open(setup_html_path) as f:
+ html_content = await f.read()
+
+ # Check if this is from Ingress - if so, pre-fill user info
+ if is_request_from_ingress(request):
+ ingress_username = request.headers.get("X-Remote-User-Name", "")
+ ingress_display_name = request.headers.get("X-Remote-User-Display-Name", "")
+
+ # Inject ingress user info into the page (use json.dumps to escape properly)
+ html_content = html_content.replace(
+ "const deviceName = urlParams.get('device_name');",
+ f"const deviceName = urlParams.get('device_name');\n"
+ f" const ingressUsername = {json.dumps(ingress_username)};\n"
+ f" const ingressDisplayName = {json.dumps(ingress_display_name)};",
+ )
+
+ return web.Response(text=html_content, content_type="text/html")
+
+ async def _handle_setup(self, request: web.Request) -> web.Response:
+ """Handle first-time setup request to create admin user."""
+ # Check if setup is still needed (allow if onboard_done is false)
+ if await self.auth.has_users() and self.mass.config.get(CONF_ONBOARD_DONE):
+ return web.json_response(
+ {"success": False, "error": "Setup already completed"}, status=400
+ )
+
+ if not request.can_read_body:
+ return web.Response(status=400, text="Body required")
+
+ body = await request.json()
+ username = body.get("username", "").strip()
+ password = body.get("password", "")
+ from_ingress = body.get("from_ingress", False)
+ display_name = body.get("display_name")
+
+ # Validation
+ if not username or len(username) < 3:
+ return web.json_response(
+ {"success": False, "error": "Username must be at least 3 characters"}, status=400
+ )
+
+ if not password or len(password) < 8:
+ return web.json_response(
+ {"success": False, "error": "Password must be at least 8 characters"}, status=400
+ )
+
+ try:
+ # Get built-in provider
+ builtin_provider = self.auth.login_providers.get("builtin")
+ if not builtin_provider:
+ return web.json_response(
+ {"success": False, "error": "Built-in auth provider not available"}, status=500
+ )
+
+ if not isinstance(builtin_provider, BuiltinLoginProvider):
+ return web.json_response(
+ {"success": False, "error": "Built-in provider configuration error"}, status=500
+ )
+
+ # Check if this is an Ingress setup where user already exists
+ user = None
+ if from_ingress and is_request_from_ingress(request):
+ ha_user_id = request.headers.get("X-Remote-User-ID")
+ if ha_user_id:
+ # Try to find existing auto-created Ingress user
+ user = await self.auth.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ha_user_id
+ )
+
+ if user:
+ # User already exists (auto-created from Ingress), update and add password
+ updates = {}
+ if display_name and not user.display_name:
+ updates["display_name"] = display_name
+ user.display_name = display_name
+
+ # Make user admin if not already
+ if user.role != UserRole.ADMIN:
+ updates["role"] = UserRole.ADMIN.value
+ user.role = UserRole.ADMIN
+
+ # Apply updates if any
+ if updates:
+ await self.auth.database.update(
+ "users",
+ {"user_id": user.user_id},
+ updates,
+ )
+
+ # Add password authentication to existing user
+ password_hash = builtin_provider._hash_password(password, username)
+ await self.auth.link_user_to_provider(user, AuthProviderType.BUILTIN, password_hash)
+ else:
+ # Create new admin user with password
+ user = await builtin_provider.create_user_with_password(
+ username, password, role=UserRole.ADMIN, display_name=display_name
+ )
+
+ # If from Ingress, also link to HA provider
+ if from_ingress and is_request_from_ingress(request):
+ ha_user_id = request.headers.get("X-Remote-User-ID")
+ if ha_user_id:
+ # Link user to Home Assistant provider
+ await self.auth.link_user_to_provider(
+ user, AuthProviderType.HOME_ASSISTANT, ha_user_id
+ )
+
+ # Create token for the new admin
+ device_name = body.get(
+ "device_name", f"Setup ({request.headers.get('User-Agent', 'Unknown')[:50]})"
+ )
+ token = await self.auth.create_token(user, device_name)
+
+ # Mark onboarding as complete
+ self.mass.config.set(CONF_ONBOARD_DONE, True)
+ self.mass.config.save(immediate=True)
+
+ self.logger.info("First admin user created: %s", username)
+
+ return web.json_response(
+ {
+ "success": True,
+ "token": token,
+ "user": user.to_dict(),
+ }
+ )
+
+ except Exception as e:
+ self.logger.exception("Error during setup")
+ return web.json_response(
+ {"success": False, "error": f"Setup failed: {e!s}"}, status=500
+ )
+
+ async def _get_ingress_user(self, request: web.Request) -> User | None:
+ """
+ Get or create user for ingress (Home Assistant) requests.
+
+ Extracts user information from Home Assistant ingress headers and either
+ finds the existing linked user or creates a new one.
+
+ :param request: The web request with HA ingress headers.
+ :return: User object or None if headers are missing.
+ """
+ ingress_user_id = request.headers.get("X-Remote-User-ID")
+ ingress_username = request.headers.get("X-Remote-User-Name")
+ ingress_display_name = request.headers.get("X-Remote-User-Display-Name")
+
+ if not ingress_user_id or not ingress_username:
+ # No user headers available
+ return None
+
+ # Try to find existing user linked to this HA user ID
+ user = await self.auth.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ if not user:
+ # Security: Ensure at least one user exists (setup should have been completed)
+ if not await self.auth.has_users():
+ self.logger.warning("Ingress request attempted before setup completed")
+ return None
+
+ # Auto-create user for Ingress (they're already authenticated by HA)
+ # Always create with USER role (admin is created during setup)
+ user = await self.auth.create_user(
+ username=ingress_username,
+ role=UserRole.USER,
+ display_name=ingress_display_name,
+ )
+ # Link to Home Assistant provider
+ await self.auth.link_user_to_provider(
+ user, AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+ self.logger.info("Auto-created ingress user: %s", ingress_username)
+
+ return user
+
+ async def _announce_to_homeassistant(self) -> None:
+ """Announce Music Assistant Ingress server to Home Assistant via Supervisor API."""
+ supervisor_token = os.environ["SUPERVISOR_TOKEN"]
+ addon_hostname = os.environ["HOSTNAME"]
+
+ # Get or create auth token for the HA system user
+ ha_integration_token = await self.auth.get_homeassistant_system_user_token()
+
+ discovery_payload = {
+ "service": "music_assistant",
+ "config": {
+ "host": addon_hostname,
+ "port": INGRESS_SERVER_PORT,
+ "auth_token": ha_integration_token,
+ },
+ }
+
+ try:
+ async with self.mass.http_session_no_ssl.post(
+ "http://supervisor/discovery",
+ headers={"Authorization": f"Bearer {supervisor_token}"},
+ json=discovery_payload,
+ timeout=ClientTimeout(total=10),
+ ) as response:
+ response.raise_for_status()
+ result = await response.json()
+ self.logger.debug(
+ "Successfully announced to Home Assistant. Discovery UUID: %s",
+ result.get("uuid"),
+ )
+ except Exception as err:
+ self.logger.warning("Failed to announce to Home Assistant: %s", err)
--- /dev/null
+"""Helpers for the webserver controller."""
--- /dev/null
+"""Authentication middleware and helpers for HTTP requests and WebSocket connections."""
+
+from __future__ import annotations
+
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, cast
+
+from aiohttp import web
+from music_assistant_models.auth import AuthProviderType, User, UserRole
+
+from music_assistant.constants import HOMEASSISTANT_SYSTEM_USER
+
+if TYPE_CHECKING:
+ from music_assistant import MusicAssistant
+
+# Context key for storing authenticated user in request
+USER_CONTEXT_KEY = "authenticated_user"
+
+# ContextVar for tracking current user and token across async calls
+current_user: ContextVar[User | None] = ContextVar("current_user", default=None)
+current_token: ContextVar[str | None] = ContextVar("current_token", default=None)
+
+
+async def get_authenticated_user(request: web.Request) -> User | None:
+ """Get authenticated user from request.
+
+ :param request: The aiohttp request.
+ """
+ # Check if user is already in context (from middleware)
+ if USER_CONTEXT_KEY in request:
+ return cast("User | None", request[USER_CONTEXT_KEY])
+
+ mass: MusicAssistant = request.app["mass"]
+
+ # Check for Home Assistant Ingress connections
+ if is_request_from_ingress(request):
+ ingress_user_id = request.headers.get("X-Remote-User-ID")
+ ingress_username = request.headers.get("X-Remote-User-Name")
+ ingress_display_name = request.headers.get("X-Remote-User-Display-Name")
+
+ # Require all Ingress headers to be present for security
+ if not (ingress_user_id and ingress_username):
+ return None
+
+ # Try to find existing user linked to this HA user ID
+ user = await mass.webserver.auth.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ if not user:
+ # Security: Ensure at least one user exists (setup should have been completed)
+ if not await mass.webserver.auth.has_users():
+ # No users exist - setup has not been completed
+ # This should not happen as the server redirects to /setup
+ return None
+
+ # Auto-create user for Ingress (they're already authenticated by HA)
+ # Always create with USER role (admin is created during setup)
+ user = await mass.webserver.auth.create_user(
+ username=ingress_username,
+ role=UserRole.USER,
+ display_name=ingress_display_name,
+ )
+ # Link to Home Assistant provider
+ await mass.webserver.auth.link_user_to_provider(
+ user, AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ # Store in request context
+ request[USER_CONTEXT_KEY] = user
+ return user
+
+ # Try to authenticate from Authorization header
+ auth_header = request.headers.get("Authorization")
+ if not auth_header:
+ return None
+
+ # Expected format: "Bearer <token>"
+ parts = auth_header.split(" ", 1)
+ if len(parts) != 2 or parts[0].lower() != "bearer":
+ return None
+
+ token = parts[1]
+
+ # Authenticate with token (works for both user tokens and API keys)
+ user = await mass.webserver.auth.authenticate_with_token(token)
+ if user:
+ # Security: Deny homeassistant system user on regular (non-Ingress) webserver
+ if not is_request_from_ingress(request) and user.username == HOMEASSISTANT_SYSTEM_USER:
+ # Reject system user on regular webserver (should only use Ingress server)
+ return None
+
+ # Store in request context
+ request[USER_CONTEXT_KEY] = user
+
+ return user
+
+
+async def require_authentication(request: web.Request) -> User:
+ """Require authentication for a request, raise 401 if not authenticated.
+
+ :param request: The aiohttp request.
+ """
+ user = await get_authenticated_user(request)
+ if not user:
+ raise web.HTTPUnauthorized(
+ text="Authentication required",
+ headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
+ )
+ return user
+
+
+async def require_admin(request: web.Request) -> User:
+ """Require admin role for a request, raise 403 if not admin.
+
+ :param request: The aiohttp request.
+ """
+ user = await require_authentication(request)
+ if user.role != UserRole.ADMIN:
+ raise web.HTTPForbidden(text="Admin access required")
+ return user
+
+
+def get_current_user() -> User | None:
+ """
+ Get the current authenticated user from context.
+
+ :return: The current user or None if not authenticated.
+ """
+ return current_user.get()
+
+
+def set_current_user(user: User | None) -> None:
+ """
+ Set the current authenticated user in context.
+
+ :param user: The user to set as current.
+ """
+ current_user.set(user)
+
+
+def get_current_token() -> str | None:
+ """
+ Get the current authentication token from context.
+
+ :return: The current token or None if not authenticated.
+ """
+ return current_token.get()
+
+
+def set_current_token(token: str | None) -> None:
+ """
+ Set the current authentication token in context.
+
+ :param token: The token to set as current.
+ """
+ current_token.set(token)
+
+
+def is_request_from_ingress(request: web.Request) -> bool:
+ """Check if request is coming from Home Assistant Ingress (internal network).
+
+ Security is enforced by socket-level verification (IP/port binding), not headers.
+ Only requests on the internal ingress TCP site (172.30.32.x:8094) are accepted.
+
+ :param request: The aiohttp request.
+ """
+ # Check if ingress site is configured in the app
+ ingress_site_params = request.app.get("ingress_site")
+ if not ingress_site_params:
+ # No ingress site configured, can't be an ingress request
+ return False
+
+ try:
+ # Security: Verify the request came through the ingress site by checking socket
+ # to prevent bypassing authentication on the regular webserver
+ transport = request.transport
+ if transport:
+ sockname = transport.get_extra_info("sockname")
+ if sockname and len(sockname) >= 2:
+ server_ip, server_port = sockname[0], sockname[1]
+ expected_ip, expected_port = ingress_site_params
+ # Request must match the ingress site's bind address and port
+ return bool(server_ip == expected_ip and server_port == expected_port)
+ except Exception: # noqa: S110
+ pass
+
+ return False
+
+
+@web.middleware
+async def auth_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
+ """Authenticate requests and store user in context.
+
+ :param request: The aiohttp request.
+ :param handler: The request handler.
+ """
+ # Skip authentication for ingress requests (HA handles auth)
+ if is_request_from_ingress(request):
+ return cast("web.StreamResponse", await handler(request))
+
+ # Unauthenticated routes (static files, info, login, setup, etc.)
+ unauthenticated_paths = [
+ "/info",
+ "/login",
+ "/setup",
+ "/auth/",
+ "/api-docs/",
+ "/assets/",
+ "/favicon.ico",
+ "/manifest.json",
+ "/index.html",
+ "/",
+ ]
+
+ # Check if path should bypass auth
+ for path_prefix in unauthenticated_paths:
+ if request.path.startswith(path_prefix):
+ return cast("web.StreamResponse", await handler(request))
+
+ # Try to authenticate
+ user = await get_authenticated_user(request)
+
+ # Store user in context (might be None for unauthenticated requests)
+ request[USER_CONTEXT_KEY] = user
+
+ # Let the handler decide if authentication is required
+ # The handler will call require_authentication() if needed
+ return cast("web.StreamResponse", await handler(request))
--- /dev/null
+"""Authentication provider base classes and implementations."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import logging
+import secrets
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TYPE_CHECKING, Any, TypedDict, cast
+from urllib.parse import urlparse
+
+from hass_client import HomeAssistantClient
+from hass_client.exceptions import BaseHassClientError
+from hass_client.utils import base_url, get_auth_url, get_token, get_websocket_url
+from music_assistant_models.auth import AuthProviderType, User, UserRole
+
+from music_assistant.constants import MASS_LOGGER_NAME
+
+if TYPE_CHECKING:
+ from music_assistant import MusicAssistant
+ from music_assistant.controllers.webserver.auth import AuthenticationManager
+ from music_assistant.providers.hass import HomeAssistantProvider
+
+LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth")
+
+
+class LoginProviderConfig(TypedDict, total=False):
+ """Base configuration for login providers."""
+
+ allow_self_registration: bool
+
+
+class HomeAssistantProviderConfig(LoginProviderConfig):
+ """Configuration for Home Assistant OAuth provider."""
+
+ ha_url: str
+
+
+@dataclass
+class AuthResult:
+ """Result of an authentication attempt."""
+
+ success: bool
+ user: User | None = None
+ error: str | None = None
+ access_token: str | None = None
+ return_url: str | None = None
+
+
+class LoginProvider(ABC):
+ """Base class for login providers."""
+
+ def __init__(self, mass: MusicAssistant, provider_id: str, config: LoginProviderConfig) -> None:
+ """
+ Initialize login provider.
+
+ :param mass: MusicAssistant instance.
+ :param provider_id: Unique identifier for this provider instance.
+ :param config: Provider-specific configuration.
+ """
+ self.mass = mass
+ self.provider_id = provider_id
+ self.config = config
+ self.logger = LOGGER
+ self.allow_self_registration = config.get("allow_self_registration", False)
+
+ @property
+ def auth_manager(self) -> AuthenticationManager:
+ """Get auth manager from webserver."""
+ return self.mass.webserver.auth
+
+ @property
+ @abstractmethod
+ def provider_type(self) -> AuthProviderType:
+ """Return the provider type."""
+
+ @property
+ @abstractmethod
+ def requires_redirect(self) -> bool:
+ """Return True if this provider requires OAuth redirect."""
+
+ @abstractmethod
+ async def authenticate(self, credentials: dict[str, Any]) -> AuthResult:
+ """
+ Authenticate user with provided credentials.
+
+ :param credentials: Provider-specific credentials (username/password, OAuth code, etc).
+ """
+
+ async def get_authorization_url(
+ self, redirect_uri: str, return_url: str | None = None
+ ) -> str | None:
+ """
+ Get OAuth authorization URL if applicable.
+
+ :param redirect_uri: The callback URL for OAuth flow.
+ :param return_url: Optional URL to redirect to after successful login.
+ """
+ return None
+
+ async def handle_oauth_callback(self, code: str, state: str, redirect_uri: str) -> AuthResult:
+ """
+ Handle OAuth callback if applicable.
+
+ :param code: OAuth authorization code.
+ :param state: OAuth state parameter for CSRF protection.
+ :param redirect_uri: The callback URL.
+ """
+ return AuthResult(success=False, error="OAuth not supported by this provider")
+
+
+class BuiltinLoginProvider(LoginProvider):
+ """Built-in username/password login provider."""
+
+ @property
+ def provider_type(self) -> AuthProviderType:
+ """Return the provider type."""
+ return AuthProviderType.BUILTIN
+
+ @property
+ def requires_redirect(self) -> bool:
+ """Return False - built-in provider doesn't need redirect."""
+ return False
+
+ async def authenticate(self, credentials: dict[str, Any]) -> AuthResult:
+ """
+ Authenticate user with username and password.
+
+ :param credentials: Dict containing 'username' and 'password'.
+ """
+ username = credentials.get("username")
+ password = credentials.get("password")
+
+ if not username or not password:
+ return AuthResult(success=False, error="Username and password required")
+
+ # First, look up user by username to get user_id
+ # This is needed to create the password hash with user_id in the salt
+ user_row = await self.auth_manager.database.get_row("users", {"username": username})
+ if not user_row:
+ return AuthResult(success=False, error="Invalid username or password")
+
+ user_id = user_row["user_id"]
+
+ # Hash the password using user_id for enhanced security
+ password_hash = self._hash_password(password, user_id)
+
+ # Verify the password by checking if provider link exists
+ user = await self.auth_manager.get_user_by_provider_link(
+ AuthProviderType.BUILTIN, password_hash
+ )
+
+ if not user:
+ return AuthResult(success=False, error="Invalid username or password")
+
+ # Check if user is enabled
+ if not user.enabled:
+ return AuthResult(success=False, error="User account is disabled")
+
+ return AuthResult(success=True, user=user)
+
+ async def create_user_with_password(
+ self,
+ username: str,
+ password: str,
+ role: UserRole = UserRole.USER,
+ display_name: str | None = None,
+ ) -> User:
+ """
+ Create a new built-in user with password.
+
+ :param username: The username.
+ :param password: The password (will be hashed).
+ :param role: The user role (default: USER).
+ :param display_name: Optional display name.
+ """
+ # Create the user
+ user = await self.auth_manager.create_user(
+ username=username,
+ role=role,
+ display_name=display_name,
+ )
+
+ # Hash password using user_id for enhanced security
+ password_hash = self._hash_password(password, user.user_id)
+ await self.auth_manager.link_user_to_provider(user, AuthProviderType.BUILTIN, password_hash)
+
+ return user
+
+ async def change_password(self, user: User, old_password: str, new_password: str) -> bool:
+ """
+ Change user password.
+
+ :param user: The user.
+ :param old_password: Current password for verification.
+ :param new_password: The new password.
+ """
+ # Verify old password first using user_id
+ old_password_hash = self._hash_password(old_password, user.user_id)
+ existing_user = await self.auth_manager.get_user_by_provider_link(
+ AuthProviderType.BUILTIN, old_password_hash
+ )
+
+ if not existing_user or existing_user.user_id != user.user_id:
+ return False
+
+ # Update password link with new hash using user_id
+ new_password_hash = self._hash_password(new_password, user.user_id)
+ await self.auth_manager.update_provider_link(
+ user, AuthProviderType.BUILTIN, new_password_hash
+ )
+
+ return True
+
+ async def reset_password(self, user: User, new_password: str) -> None:
+ """
+ Reset user password (admin only - no old password verification).
+
+ :param user: The user whose password to reset.
+ :param new_password: The new password.
+ """
+ # Hash new password using user_id and update provider link
+ new_password_hash = self._hash_password(new_password, user.user_id)
+ await self.auth_manager.update_provider_link(
+ user, AuthProviderType.BUILTIN, new_password_hash
+ )
+
+ def _hash_password(self, password: str, user_id: str) -> str:
+ """
+ Hash password with salt combining user ID and server ID.
+
+ :param password: Plain text password.
+ :param user_id: User ID to include in salt (random token for high entropy).
+ """
+ # Combine user_id (random) and server_id for maximum security
+ salt = f"{user_id}:{self.mass.server_id}"
+ return hashlib.pbkdf2_hmac(
+ "sha256", password.encode(), salt.encode(), iterations=100000
+ ).hex()
+
+
+class HomeAssistantOAuthProvider(LoginProvider):
+ """Home Assistant OAuth login provider."""
+
+ @property
+ def provider_type(self) -> AuthProviderType:
+ """Return the provider type."""
+ return AuthProviderType.HOME_ASSISTANT
+
+ @property
+ def requires_redirect(self) -> bool:
+ """Return True - Home Assistant OAuth requires redirect."""
+ return True
+
+ async def authenticate(self, credentials: dict[str, Any]) -> AuthResult:
+ """
+ Not used for OAuth providers - use handle_oauth_callback instead.
+
+ :param credentials: Not used.
+ """
+ return AuthResult(success=False, error="Use OAuth flow for Home Assistant authentication")
+
+ async def _get_external_ha_url(self) -> str | None:
+ """
+ Get the external URL for Home Assistant from the config API.
+
+ This is needed when MA runs as HA add-on and connects via internal docker network
+ (http://supervisor/api) but needs the external URL for OAuth redirects.
+
+ :return: External URL if available, otherwise None.
+ """
+ ha_url = cast("str", self.config.get("ha_url")) if self.config.get("ha_url") else None
+ if not ha_url:
+ return None
+
+ # Check if we're using the internal supervisor URL
+ if "supervisor" not in ha_url.lower():
+ # Not using internal URL, return as-is
+ return ha_url
+
+ # We're using internal URL - try to get external URL from HA provider
+ ha_provider = self.mass.get_provider("hass")
+ if not ha_provider:
+ # No HA provider available, use configured URL
+ return ha_url
+
+ ha_provider = cast("HomeAssistantProvider", ha_provider)
+
+ try:
+ # Access the hass client from the provider
+ hass_client = ha_provider.hass
+ if not hass_client or not hass_client.connected:
+ return ha_url
+
+ # Get network URLs from Home Assistant using WebSocket API
+ # This command returns internal, external, and cloud URLs
+ network_urls = await hass_client.send_command("network/url")
+
+ if network_urls:
+ # Priority: external > cloud > internal
+ # External is the manually configured external URL
+ # Cloud is the Nabu Casa cloud URL
+ # Internal is the local network URL
+ external_url = network_urls.get("external")
+ cloud_url = network_urls.get("cloud")
+ internal_url = network_urls.get("internal")
+
+ # Use external URL first, then cloud, then internal
+ final_url = cast("str", external_url or cloud_url or internal_url)
+ if final_url:
+ self.logger.debug(
+ "Using HA URL for OAuth: %s (from network/url, configured: %s)",
+ final_url,
+ ha_url,
+ )
+ return final_url
+ except Exception as err:
+ self.logger.warning("Failed to fetch HA network URLs: %s", err, exc_info=True)
+
+ # Fallback to configured URL
+ return ha_url
+
+ async def get_authorization_url(
+ self, redirect_uri: str, return_url: str | None = None
+ ) -> str | None:
+ """
+ Get Home Assistant OAuth authorization URL using hass_client.
+
+ :param redirect_uri: The callback URL.
+ :param return_url: Optional URL to redirect to after successful login.
+ """
+ # Get the correct HA URL (external URL if running as add-on)
+ ha_url = await self._get_external_ha_url()
+ if not ha_url:
+ return None
+
+ # If HA URL is still the internal supervisor URL (no external_url in HA config),
+ # infer from redirect_uri (the URL user is accessing MA from)
+ if "supervisor" in ha_url.lower():
+ # Extract scheme and host from redirect_uri to build external HA URL
+ parsed = urlparse(redirect_uri)
+ # HA typically runs on port 8123, but use default ports for HTTPS (443) or HTTP (80)
+ if parsed.scheme == "https":
+ # HTTPS - use default port 443 (no port in URL)
+ inferred_ha_url = f"{parsed.scheme}://{parsed.hostname}"
+ else:
+ # HTTP - assume HA runs on default port 8123
+ inferred_ha_url = f"{parsed.scheme}://{parsed.hostname}:8123"
+
+ self.logger.debug(
+ "HA external_url not configured, inferring from callback URL: %s",
+ inferred_ha_url,
+ )
+ ha_url = inferred_ha_url
+
+ state = secrets.token_urlsafe(32)
+ # Store state and return_url for verification and final redirect
+ self._oauth_state = state
+ self._oauth_return_url = return_url
+
+ # Use base_url of callback as client_id (same as HA provider does)
+ client_id = base_url(redirect_uri)
+
+ # Use hass_client's get_auth_url utility
+ return cast(
+ "str",
+ get_auth_url(
+ ha_url,
+ redirect_uri,
+ client_id=client_id,
+ state=state,
+ ),
+ )
+
+ def _decode_ha_jwt_token(self, access_token: str) -> tuple[str | None, str | None]:
+ """
+ Decode Home Assistant JWT token to extract user ID and name.
+
+ :param access_token: The JWT access token from Home Assistant.
+ :return: Tuple of (user_id, username) or (None, None) if decoding fails.
+ """
+ try:
+ # JWT tokens have 3 parts separated by dots: header.payload.signature
+ parts = access_token.split(".")
+ if len(parts) >= 2:
+ # Decode the payload (second part)
+ # Add padding if needed (JWT base64 may not be padded)
+ payload = parts[1]
+ payload += "=" * (4 - len(payload) % 4)
+ decoded = base64.urlsafe_b64decode(payload)
+ token_data = json.loads(decoded)
+
+ # Home Assistant JWT tokens use 'iss' as the user ID
+ ha_user_id: str | None = token_data.get("iss")
+
+ if not ha_user_id:
+ # Fallback to 'sub' if 'iss' is not present
+ ha_user_id = token_data.get("sub")
+
+ # Try to extract username from token (name, username, or other fields)
+ username = token_data.get("name") or token_data.get("username")
+
+ if ha_user_id:
+ return str(ha_user_id), username
+ return None, None
+ except Exception as decode_error:
+ self.logger.error("Failed to decode HA JWT token: %s", decode_error)
+
+ return None, None
+
+ async def _fetch_ha_user_via_websocket(
+ self, ha_url: str, access_token: str
+ ) -> tuple[str | None, str | None]:
+ """
+ Fetch user information from Home Assistant via WebSocket.
+
+ :param ha_url: Home Assistant URL.
+ :param access_token: Access token for WebSocket authentication.
+ :return: Tuple of (username, display_name) or (None, None) if fetch fails.
+ """
+ ws_url = get_websocket_url(ha_url)
+
+ try:
+ # Use context manager to automatically handle connect/disconnect
+ async with HomeAssistantClient(ws_url, access_token, self.mass.http_session) as client:
+ # Use the auth/current_user command to get user details
+ result = await client.send_command("auth/current_user")
+
+ if result:
+ # Extract username and display name from response
+ username = result.get("name") or result.get("username")
+ display_name = result.get("name")
+ if username:
+ return username, display_name
+
+ self.logger.warning("auth/current_user returned no user data")
+ return None, None
+
+ except BaseHassClientError as ws_error:
+ self.logger.error("Failed to fetch HA user via WebSocket: %s", ws_error)
+ return None, None
+
+ async def _get_or_create_user(
+ self, username: str, display_name: str | None, ha_user_id: str
+ ) -> User | None:
+ """
+ Get or create a user for Home Assistant OAuth authentication.
+
+ :param username: Username from Home Assistant.
+ :param display_name: Display name from Home Assistant.
+ :param ha_user_id: Home Assistant user ID.
+ :return: User object or None if creation failed.
+ """
+ # Check if user already linked to HA
+ user = await self.auth_manager.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ha_user_id
+ )
+ if user:
+ return user
+
+ # Check if a user with this username already exists (from built-in provider)
+ user_row = await self.auth_manager.database.get_row("users", {"username": username})
+ if user_row:
+ # User exists with this username - link them to HA provider
+ user_dict = dict(user_row)
+ existing_user = User(
+ user_id=user_dict["user_id"],
+ username=user_dict["username"],
+ role=UserRole(user_dict["role"]),
+ enabled=bool(user_dict["enabled"]),
+ created_at=datetime.fromisoformat(user_dict["created_at"]),
+ display_name=user_dict["display_name"],
+ avatar_url=user_dict["avatar_url"],
+ )
+
+ # Link existing user to Home Assistant
+ await self.auth_manager.link_user_to_provider(
+ existing_user, AuthProviderType.HOME_ASSISTANT, ha_user_id
+ )
+
+ self.logger.debug("Linked existing user '%s' to Home Assistant provider", username)
+ return existing_user
+
+ # New HA user - check if self-registration allowed
+ if not self.allow_self_registration:
+ return None
+
+ # Create new user with USER role
+ user = await self.auth_manager.create_user(
+ username=username,
+ role=UserRole.USER,
+ display_name=display_name or username,
+ )
+
+ # Link to Home Assistant
+ await self.auth_manager.link_user_to_provider(
+ user, AuthProviderType.HOME_ASSISTANT, ha_user_id
+ )
+
+ return user
+
+ async def handle_oauth_callback(self, code: str, state: str, redirect_uri: str) -> AuthResult:
+ """
+ Handle Home Assistant OAuth callback using hass_client.
+
+ :param code: OAuth authorization code.
+ :param state: OAuth state parameter.
+ :param redirect_uri: The callback URL.
+ """
+ # Verify state
+ if not hasattr(self, "_oauth_state") or state != self._oauth_state:
+ return AuthResult(success=False, error="Invalid state parameter")
+
+ # Get the correct HA URL (external URL if running as add-on)
+ # This must be the same URL used in get_authorization_url
+ ha_url = await self._get_external_ha_url()
+ if not ha_url:
+ return AuthResult(success=False, error="Home Assistant URL not configured")
+
+ try:
+ # Use base_url of callback as client_id (same as HA provider does)
+ client_id = base_url(redirect_uri)
+
+ # Use hass_client's get_token utility - no client_secret needed!
+ try:
+ token_details = await get_token(ha_url, code, client_id=client_id)
+ except Exception as token_error:
+ self.logger.error(
+ "Failed to get token from HA: %s (client_id: %s, ha_url: %s)",
+ token_error,
+ client_id,
+ ha_url,
+ )
+ return AuthResult(
+ success=False, error=f"Failed to exchange OAuth code: {token_error}"
+ )
+
+ access_token = token_details.get("access_token")
+ if not access_token:
+ return AuthResult(success=False, error="No access token received from HA")
+
+ # Decode JWT token to get HA user ID
+ ha_user_id, _ = self._decode_ha_jwt_token(access_token)
+ if not ha_user_id:
+ return AuthResult(success=False, error="Failed to decode token")
+
+ # Fetch user information from HA via WebSocket
+ username, display_name = await self._fetch_ha_user_via_websocket(ha_url, access_token)
+
+ # If we couldn't get username from WebSocket, fail authentication
+ if not username:
+ return AuthResult(
+ success=False,
+ error="Failed to get username from Home Assistant",
+ )
+
+ # Get or create user
+ user = await self._get_or_create_user(username, display_name, ha_user_id)
+
+ # Get stored return_url from OAuth state
+ return_url = getattr(self, "_oauth_return_url", None)
+
+ if not user:
+ return AuthResult(
+ success=False,
+ error="Self-registration is disabled. Please contact an administrator.",
+ )
+
+ return AuthResult(success=True, user=user, return_url=return_url)
+
+ except Exception as e:
+ self.logger.exception("Error during Home Assistant OAuth callback")
+ return AuthResult(success=False, error=str(e))
--- /dev/null
+"""WebSocket client handler for Music Assistant API."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from concurrent import futures
+from contextlib import suppress
+from typing import TYPE_CHECKING, Any, Final
+
+from aiohttp import WSMsgType, web
+from music_assistant_models.api import (
+ CommandMessage,
+ ErrorResultMessage,
+ MessageType,
+ SuccessResultMessage,
+)
+from music_assistant_models.auth import AuthProviderType, UserRole
+from music_assistant_models.errors import (
+ AuthenticationRequired,
+ InsufficientPermissions,
+ InvalidCommand,
+ InvalidToken,
+)
+
+from music_assistant.constants import HOMEASSISTANT_SYSTEM_USER, VERBOSE_LOG_LEVEL
+from music_assistant.helpers.api import APICommandHandler, parse_arguments
+
+from .helpers.auth_middleware import is_request_from_ingress, set_current_token, set_current_user
+
+if TYPE_CHECKING:
+ from music_assistant.controllers.webserver import WebserverController
+
+MAX_PENDING_MSG = 512
+CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
+
+
+class WebsocketClientHandler:
+ """Handle an active websocket client connection."""
+
+ def __init__(self, webserver: WebserverController, request: web.Request) -> None:
+ """Initialize an active connection."""
+ self.webserver = webserver
+ self.mass = webserver.mass
+ self.request = request
+ self.wsock = web.WebSocketResponse(heartbeat=55)
+ self._to_write: asyncio.Queue[str | None] = asyncio.Queue(maxsize=MAX_PENDING_MSG)
+ self._handle_task: asyncio.Task[Any] | None = None
+ self._writer_task: asyncio.Task[None] | None = None
+ self._logger = webserver.logger
+ self._authenticated_user: Any = None # Will be set after auth command or from Ingress
+ self._current_token: str | None = None # Will be set after auth command
+ self._token_id: str | None = None # Will be set after auth for tracking revocation
+ self._is_ingress = is_request_from_ingress(request)
+ self._events_unsub_callback: Any = None # Will be set after authentication
+ # try to dynamically detect the base_url of a client if proxied or behind Ingress
+ self.base_url: str | None = None
+ if forward_host := request.headers.get("X-Forwarded-Host"):
+ ingress_path = request.headers.get("X-Ingress-Path", "")
+ forward_proto = request.headers.get("X-Forwarded-Proto", request.protocol)
+ self.base_url = f"{forward_proto}://{forward_host}{ingress_path}"
+
+ async def disconnect(self) -> None:
+ """Disconnect client."""
+ self._cancel()
+ if self._writer_task is not None:
+ await self._writer_task
+
+ async def handle_client(self) -> web.WebSocketResponse:
+ """Handle a websocket response."""
+ # ruff: noqa: PLR0915
+ request = self.request
+ wsock = self.wsock
+ try:
+ async with asyncio.timeout(10):
+ await wsock.prepare(request)
+ except TimeoutError:
+ self._logger.warning("Timeout preparing request from %s", request.remote)
+ return wsock
+
+ self._logger.log(VERBOSE_LOG_LEVEL, "Connection from %s", request.remote)
+ self._handle_task = asyncio.current_task()
+ self._writer_task = self.mass.create_task(self._writer())
+
+ # send server(version) info when client connects
+ server_info = self.mass.get_server_info()
+ await self._send_message(server_info)
+
+ # For Ingress connections, auto-create/link user and subscribe to events immediately
+ # For regular connections, events will be subscribed after successful authentication
+ if self._is_ingress:
+ await self._handle_ingress_auth()
+ self._subscribe_to_events()
+
+ disconnect_warn = None
+
+ try:
+ while not wsock.closed:
+ msg = await wsock.receive()
+
+ if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
+ break
+
+ if msg.type != WSMsgType.TEXT:
+ continue
+
+ self._logger.log(VERBOSE_LOG_LEVEL, "Received: %s", msg.data)
+
+ try:
+ command_msg = CommandMessage.from_json(msg.data)
+ except ValueError:
+ disconnect_warn = f"Received invalid JSON: {msg.data}"
+ break
+
+ await self._handle_command(command_msg)
+
+ except asyncio.CancelledError:
+ self._logger.debug("Connection closed by client")
+
+ except Exception:
+ self._logger.exception("Unexpected error inside websocket API")
+
+ finally:
+ # Handle connection shutting down.
+ if self._events_unsub_callback:
+ self._events_unsub_callback()
+ self._logger.log(VERBOSE_LOG_LEVEL, "Unsubscribed from events")
+
+ # Unregister from webserver tracking
+ self.webserver.unregister_websocket_client(self)
+
+ try:
+ self._to_write.put_nowait(None)
+ # Make sure all error messages are written before closing
+ await self._writer_task
+ await wsock.close()
+ except asyncio.QueueFull: # can be raised by put_nowait
+ self._writer_task.cancel()
+
+ finally:
+ if disconnect_warn is None:
+ self._logger.log(VERBOSE_LOG_LEVEL, "Disconnected")
+ else:
+ self._logger.warning("Disconnected: %s", disconnect_warn)
+
+ return wsock
+
+ async def _handle_command(self, msg: CommandMessage) -> None:
+ """Handle an incoming command from the client."""
+ self._logger.debug("Handling command %s", msg.command)
+
+ # Handle special "auth" command
+ if msg.command == "auth":
+ await self._handle_auth_command(msg)
+ return
+
+ # work out handler for the given path/command
+ handler = self.mass.command_handlers.get(msg.command)
+
+ if handler is None:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ InvalidCommand.error_code,
+ f"Invalid command: {msg.command}",
+ )
+ )
+ self._logger.warning("Invalid command: %s", msg.command)
+ return
+
+ # Check authentication if required
+ if handler.authenticated or handler.required_role:
+ # For Ingress, user should already be set from _handle_ingress_auth
+ # For regular connections, user must be set via auth command
+ if self._authenticated_user is None:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ AuthenticationRequired.error_code,
+ "Authentication required. Please send auth command first.",
+ )
+ )
+ return
+
+ # Set user and token in context for API methods
+ set_current_user(self._authenticated_user)
+ set_current_token(self._current_token)
+
+ # Check role if required
+ if handler.required_role == "admin":
+ if self._authenticated_user.role != UserRole.ADMIN:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ InsufficientPermissions.error_code,
+ "Admin access required",
+ )
+ )
+ return
+
+ # schedule task to handle the command
+ self.mass.create_task(self._run_handler(handler, msg))
+
+ async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None:
+ """Run command handler and send response."""
+ try:
+ args = parse_arguments(handler.signature, handler.type_hints, msg.args)
+ result: Any = handler.target(**args)
+ if hasattr(result, "__anext__"):
+ # handle async generator (for really large listings)
+ items: list[Any] = []
+ async for item in result:
+ items.append(item)
+ if len(items) >= 500:
+ await self._send_message(
+ SuccessResultMessage(msg.message_id, items, partial=True)
+ )
+ items = []
+ result = items
+ elif asyncio.iscoroutine(result):
+ result = await result
+ await self._send_message(SuccessResultMessage(msg.message_id, result))
+ except Exception as err:
+ if self._logger.isEnabledFor(logging.DEBUG):
+ self._logger.exception("Error handling message: %s", msg)
+ else:
+ self._logger.error("Error handling message: %s: %s", msg.command, str(err))
+ err_msg = str(err) or err.__class__.__name__
+ await self._send_message(
+ ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), err_msg)
+ )
+
+ async def _writer(self) -> None:
+ """Write outgoing messages."""
+ # Exceptions if Socket disconnected or cancelled by connection handler
+ with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
+ while not self.wsock.closed:
+ if (process := await self._to_write.get()) is None:
+ break
+
+ if callable(process):
+ message: str = process()
+ else:
+ message = process
+ self._logger.log(VERBOSE_LOG_LEVEL, "Writing: %s", message)
+ await self.wsock.send_str(message)
+
+ async def _send_message(self, message: MessageType) -> None:
+ """Send a message to the client (for large response messages).
+
+ Runs JSON serialization in executor to avoid blocking for large messages.
+ Closes connection if the client is not reading the messages.
+
+ Async friendly.
+ """
+ # Run JSON serialization in executor to avoid blocking for large messages
+ loop = asyncio.get_running_loop()
+ _message = await loop.run_in_executor(None, message.to_json)
+
+ try:
+ self._to_write.put_nowait(_message)
+ except asyncio.QueueFull:
+ self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
+
+ self._cancel()
+
+ def _send_message_sync(self, message: MessageType) -> None:
+ """Send a message from a sync context (for small messages like events).
+
+ Serializes inline without executor overhead since events are typically small.
+ """
+ _message = message.to_json()
+
+ try:
+ self._to_write.put_nowait(_message)
+ except asyncio.QueueFull:
+ self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
+
+ self._cancel()
+
+ async def _handle_auth_command(self, msg: CommandMessage) -> None:
+ """Handle WebSocket authentication command.
+
+ :param msg: The auth command message with access token.
+ """
+ # Extract token from args (support both 'token' and 'access_token' for backward compat)
+ token = msg.args.get("token") if msg.args else None
+ if not token:
+ token = msg.args.get("access_token") if msg.args else None
+ if not token:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ AuthenticationRequired.error_code,
+ "token required in args",
+ )
+ )
+ return
+
+ # Authenticate with token
+ user = await self.webserver.auth.authenticate_with_token(token)
+ if not user:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ InvalidToken.error_code,
+ "Invalid or expired token",
+ )
+ )
+ return
+
+ # Security: Deny homeassistant system user on regular (non-Ingress) webserver
+ if not self._is_ingress and user.username == HOMEASSISTANT_SYSTEM_USER:
+ await self._send_message(
+ ErrorResultMessage(
+ msg.message_id,
+ InvalidToken.error_code,
+ "Home Assistant system user not allowed on regular webserver",
+ )
+ )
+ return
+
+ # Get token_id for tracking revocation events
+ token_id = await self.webserver.auth.get_token_id_from_token(token)
+
+ # Store authenticated user, token, and token_id
+ self._authenticated_user = user
+ self._current_token = token
+ self._token_id = token_id
+ self._logger.info("WebSocket client authenticated as %s", user.username)
+
+ # Send success response
+ await self._send_message(
+ SuccessResultMessage(
+ msg.message_id,
+ {"authenticated": True, "user": user.to_dict()},
+ )
+ )
+
+ # Subscribe to events after successful authentication
+ self._subscribe_to_events()
+
+ # Register with webserver for tracking
+ self.webserver.register_websocket_client(self)
+
+ async def _handle_ingress_auth(self) -> None:
+ """Handle authentication for Ingress connections (auto-create/link user)."""
+ ingress_user_id = self.request.headers.get("X-Remote-User-ID")
+ ingress_username = self.request.headers.get("X-Remote-User-Name")
+ ingress_display_name = self.request.headers.get("X-Remote-User-Display-Name")
+
+ if ingress_user_id and ingress_username:
+ # Try to find existing user linked to this HA user ID
+ user = await self.webserver.auth.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ if not user:
+ # Security: Ensure at least one user exists (setup should have been completed)
+ if not await self.webserver.auth.has_users():
+ # No users exist - setup has not been completed
+ # This should not happen as the server redirects to /setup
+ self._logger.warning("Ingress connection attempted before setup completed")
+ return
+
+ # Auto-create user for Ingress (they're already authenticated by HA)
+ # Always create with USER role (admin is created during setup)
+ user = await self.webserver.auth.create_user(
+ username=ingress_username,
+ role=UserRole.USER,
+ display_name=ingress_display_name,
+ )
+ # Link to Home Assistant provider
+ await self.webserver.auth.link_user_to_provider(
+ user, AuthProviderType.HOME_ASSISTANT, ingress_user_id
+ )
+
+ self._authenticated_user = user
+ self._logger.debug("Ingress user authenticated: %s", user.username)
+ else:
+ # No HA user headers - allow homeassistant system user to connect with token
+ # This allows the Home Assistant integration to connect via the internal network
+ # The token authentication happens in _handle_auth_message
+ self._logger.debug("Ingress connection without user headers, expecting token auth")
+
+ def _subscribe_to_events(self) -> None:
+ """Subscribe to Mass events and forward them to the client."""
+ if self._events_unsub_callback is not None:
+ # Already subscribed
+ return
+
+ def handle_event(event: Any) -> None:
+ # event is MassEvent but we use Any to avoid runtime import
+ self._send_message_sync(event)
+
+ self._events_unsub_callback = self.mass.subscribe(handle_event)
+ self._logger.debug("Subscribed to events")
+
+ def _cancel(self) -> None:
+ """Cancel the connection."""
+ if self._handle_task is not None:
+ self._handle_task.cancel()
+ if self._writer_task is not None:
+ self._writer_task.cancel()
from __future__ import annotations
+import importlib
import inspect
import logging
from collections.abc import AsyncGenerator, Callable, Coroutine
_F = TypeVar("_F", bound=Callable[..., Any])
+# Cache for resolved type alias strings to avoid repeated imports
+_TYPE_ALIAS_CACHE: dict[str, Any] = {}
+
+
+def _resolve_string_type(type_str: str) -> Any:
+ """
+ Resolve a string type reference back to the actual type.
+
+ This is needed when type aliases like ConfigValueType are converted to strings
+ during type hint resolution to avoid isinstance() errors with complex unions.
+
+ Uses a module-level cache to avoid repeated imports.
+
+ :param type_str: String name of the type (e.g., "ConfigValueType").
+ :return: The actual type object, or the string if resolution fails.
+ """
+ # Check cache first
+ if type_str in _TYPE_ALIAS_CACHE:
+ return _TYPE_ALIAS_CACHE[type_str]
+
+ type_alias_map = {
+ "ConfigValueType": ("music_assistant_models.config_entries", "ConfigValueType"),
+ "MediaItemType": ("music_assistant_models.media_items", "MediaItemType"),
+ }
+
+ if type_str not in type_alias_map:
+ # Cache the string itself for unknown types
+ _TYPE_ALIAS_CACHE[type_str] = type_str
+ return type_str
+
+ module_name, type_name = type_alias_map[type_str]
+ try:
+ module = importlib.import_module(module_name)
+ resolved_type = getattr(module, type_name)
+ # Cache the successfully resolved type
+ _TYPE_ALIAS_CACHE[type_str] = resolved_type
+ return resolved_type
+ except (ImportError, AttributeError) as err:
+ LOGGER.warning("Failed to resolve type alias %s: %s", type_str, err)
+ # Cache the string to avoid repeated failed attempts
+ _TYPE_ALIAS_CACHE[type_str] = type_str
+ return type_str
+
+
+def _resolve_generic_type_args(
+ args: tuple[Any, ...],
+ func: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]],
+ config_value_type: Any,
+ media_item_type: Any,
+) -> tuple[list[Any], bool]:
+ """Resolve TypeVars and type aliases in generic type arguments.
+
+ :param args: Type arguments from a generic type (e.g., from list[T] or dict[K, V])
+ :param func: The function being analyzed
+ :param config_value_type: The ConfigValueType type alias to compare against
+ :param media_item_type: The MediaItemType type alias to compare against
+ :return: Tuple of (resolved_args, changed) where changed is True if any args were modified
+ """
+ new_args: list[Any] = []
+ changed = False
+
+ for arg in args:
+ # Check if arg matches ConfigValueType union (type alias that was expanded)
+ if arg == config_value_type:
+ # Replace with string reference to preserve type alias
+ new_args.append("ConfigValueType")
+ changed = True
+ # Check if arg matches MediaItemType union (type alias that was expanded)
+ elif arg == media_item_type:
+ # Replace with string reference to preserve type alias
+ new_args.append("MediaItemType")
+ changed = True
+ elif isinstance(arg, TypeVar):
+ # For ItemCls, resolve to concrete type
+ if arg.__name__ == "ItemCls" and hasattr(func, "__self__"):
+ if hasattr(func.__self__, "item_cls"):
+ new_args.append(func.__self__.item_cls)
+ changed = True
+ else:
+ new_args.append(arg)
+ # For ConfigValue TypeVars, resolve to string name
+ elif "ConfigValue" in arg.__name__:
+ new_args.append("ConfigValueType")
+ changed = True
+ else:
+ new_args.append(arg)
+ # Check if arg is a Union containing a TypeVar
+ elif get_origin(arg) in (Union, UnionType):
+ union_args = get_args(arg)
+ for union_arg in union_args:
+ if isinstance(union_arg, TypeVar) and union_arg.__bound__ is not None:
+ # Resolve the TypeVar in the union
+ union_arg_index = union_args.index(union_arg)
+ resolved = _resolve_typevar_in_union(
+ union_arg, func, union_args, union_arg_index
+ )
+ new_args.append(resolved)
+ changed = True
+ break
+ else:
+ # No TypeVar found in union, keep as-is
+ new_args.append(arg)
+ else:
+ new_args.append(arg)
+
+ return new_args, changed
+
+
+def _resolve_typevar_in_union(
+ arg: TypeVar,
+ func: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]],
+ args: tuple[Any, ...],
+ i: int,
+) -> Any:
+ """Resolve a TypeVar found in a Union to its concrete type.
+
+ :param arg: The TypeVar to resolve.
+ :param func: The function being analyzed.
+ :param args: All args from the Union.
+ :param i: Index of the TypeVar in the args.
+ """
+ bound_type = arg.__bound__
+ if not bound_type or not hasattr(arg, "__name__"):
+ return bound_type
+
+ type_var_name = arg.__name__
+
+ # Map TypeVar names to their type alias names
+ if "ConfigValue" in type_var_name:
+ return "ConfigValueType"
+
+ if type_var_name == "ItemCls":
+ # Resolve ItemCls to the actual media item class (e.g., Artist, Album, Track)
+ if hasattr(func, "__self__") and hasattr(func.__self__, "item_cls"):
+ resolved_type = func.__self__.item_cls
+ # Preserve other types in the union (like None for Optional)
+ other_args = [a for j, a in enumerate(args) if j != i]
+ if other_args:
+ # Reconstruct union with resolved type
+ return Union[resolved_type, *other_args]
+ return resolved_type
+ # Fallback to bound if we can't get item_cls
+ return bound_type
+
+ # Check if the bound is MediaItemType by comparing the union
+ from music_assistant_models.media_items import ( # noqa: PLC0415
+ MediaItemType as media_item_type, # noqa: N813
+ )
+
+ if bound_type == media_item_type:
+ return "MediaItemType"
+
+ # Fallback to the bound type
+ return bound_type
+
@dataclass
class APICommandHandler:
signature: inspect.Signature
type_hints: dict[str, Any]
target: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]]
+ authenticated: bool = True
+ required_role: str | None = None # "admin" or "user" or None
+ alias: bool = False # If True, this is an alias for backward compatibility
@classmethod
def parse(
- cls, command: str, func: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]]
+ cls,
+ command: str,
+ func: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]],
+ authenticated: bool = True,
+ required_role: str | None = None,
+ alias: bool = False,
) -> APICommandHandler:
- """Parse APICommandHandler by providing a function."""
+ """Parse APICommandHandler by providing a function.
+
+ :param command: The command name/path.
+ :param func: The function to handle the command.
+ :param authenticated: Whether authentication is required (default: True).
+ :param required_role: Required user role ("admin" or "user")
+ None for any authenticated user.
+ :param alias: Whether this is an alias for backward compatibility (default: False).
+ """
type_hints = get_type_hints(func)
# workaround for generic typevar ItemCls that needs to be resolved
# to the real media item type. TODO: find a better way to do this
# without this hack
+ # Import type aliases to compare against
+ from music_assistant_models.config_entries import ( # noqa: PLC0415
+ ConfigValueType as config_value_type, # noqa: N813
+ )
+ from music_assistant_models.media_items import ( # noqa: PLC0415
+ MediaItemType as media_item_type, # noqa: N813
+ )
+
for key, value in type_hints.items():
+ # Handle generic types (list, tuple, dict, etc.) that may contain TypeVars
+ # For example: list[ItemCls] should become list[Artist]
+ # For example: dict[str, ConfigValueType] should preserve ConfigValueType
+ origin = get_origin(value)
+ if origin in (list, tuple, set, frozenset, dict):
+ args = get_args(value)
+ if args:
+ new_args, changed = _resolve_generic_type_args(
+ args, func, config_value_type, media_item_type
+ )
+ if changed:
+ # Reconstruct the generic type with resolved TypeVars
+ type_hints[key] = origin[tuple(new_args)]
+ continue
+
+ # Handle Union types that may contain TypeVars
+ # For example: _ConfigValueT | ConfigValueType should become just "ConfigValueType"
+ # when _ConfigValueT is bound to ConfigValueType
+ if origin is Union or origin is UnionType:
+ args = get_args(value)
+ # Check if union contains a TypeVar
+ # If the TypeVar's bound is a union that was flattened into the current union,
+ # we can just use the bound type for documentation purposes
+ typevar_found = False
+ for i, arg in enumerate(args):
+ if isinstance(arg, TypeVar) and arg.__bound__ is not None:
+ typevar_found = True
+ type_hints[key] = _resolve_typevar_in_union(arg, func, args, i)
+ break
+ if typevar_found:
+ continue
if not hasattr(value, "__name__"):
continue
if value.__name__ == "ItemCls":
type_hints[key] = func.__self__.item_cls # type: ignore[attr-defined]
+ # Resolve TypeVars to their bound type for API documentation
+ # This handles cases like _ConfigValueT which should show as ConfigValueType
+ elif isinstance(value, TypeVar):
+ if value.__bound__ is not None:
+ type_hints[key] = value.__bound__
return APICommandHandler(
command=command,
signature=inspect.signature(func),
type_hints=type_hints,
target=func,
+ authenticated=authenticated,
+ required_role=required_role,
+ alias=alias,
)
-def api_command(command: str) -> Callable[[_F], _F]:
- """Decorate a function as API route/command."""
+def api_command(
+ command: str, authenticated: bool = True, required_role: str | None = None
+) -> Callable[[_F], _F]:
+ """Decorate a function as API route/command.
+
+ :param command: The command name/path.
+ :param authenticated: Whether authentication is required (default: True).
+ :param required_role: Required user role ("admin" or "user"), None means any authenticated user.
+ """
def decorate(func: _F) -> _F:
func.api_cmd = command # type: ignore[attr-defined]
+ func.api_authenticated = authenticated # type: ignore[attr-defined]
+ func.api_required_role = required_role # type: ignore[attr-defined]
return func
return decorate
allow_value_convert: bool = False,
) -> Any:
"""Try to parse a value from raw (json) data and type annotations."""
+ # Resolve string type hints early for proper handling
+ if isinstance(value_type, str):
+ value_type = _resolve_string_type(value_type)
+ # If still a string after resolution, return value as-is
+ if isinstance(value_type, str):
+ LOGGER.debug("Unknown string type hint: %s, returning value as-is", value_type)
+ return value
+
if isinstance(value, dict) and hasattr(value_type, "from_dict"):
if (
"media_type" in value
+++ /dev/null
-"""Helpers for generating API documentation and OpenAPI specifications."""
-
-from __future__ import annotations
-
-import collections.abc
-import inspect
-from collections.abc import Callable
-from dataclasses import MISSING
-from datetime import datetime
-from enum import Enum
-from types import NoneType, UnionType
-from typing import Any, Union, get_args, get_origin, get_type_hints
-
-from music_assistant_models.player import Player as PlayerState
-
-from music_assistant.helpers.api import APICommandHandler
-
-
-def _format_type_name(type_hint: Any) -> str:
- """Format a type hint as a user-friendly string, using JSON types instead of Python types."""
- if type_hint is NoneType or type_hint is type(None):
- return "null"
-
- # Handle internal Player model - replace with PlayerState
- if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
- if (
- hasattr(type_hint, "__module__")
- and type_hint.__module__ == "music_assistant.models.player"
- ):
- return "PlayerState"
-
- # Map Python types to JSON types
- type_name_mapping = {
- "str": "string",
- "int": "integer",
- "float": "number",
- "bool": "boolean",
- "dict": "object",
- "list": "array",
- "tuple": "array",
- "set": "array",
- "frozenset": "array",
- "Sequence": "array",
- "UniqueList": "array",
- "None": "null",
- }
-
- if hasattr(type_hint, "__name__"):
- type_name = str(type_hint.__name__)
- return type_name_mapping.get(type_name, type_name)
-
- type_str = str(type_hint).replace("NoneType", "null")
- # Replace Python types with JSON types in complex type strings
- for python_type, json_type in type_name_mapping.items():
- type_str = type_str.replace(python_type, json_type)
- return type_str
-
-
-def _get_type_schema( # noqa: PLR0911, PLR0915
- type_hint: Any, definitions: dict[str, Any]
-) -> dict[str, Any]:
- """Convert a Python type hint to an OpenAPI schema."""
- # Handle string type hints from __future__ annotations
- if isinstance(type_hint, str):
- # Handle simple primitive type names
- if type_hint in ("str", "string"):
- return {"type": "string"}
- if type_hint in ("int", "integer"):
- return {"type": "integer"}
- if type_hint in ("float", "number"):
- return {"type": "number"}
- if type_hint in ("bool", "boolean"):
- return {"type": "boolean"}
-
- # Check if it looks like a simple class name (no special chars, starts with uppercase)
- # Examples: "PlayerType", "DeviceInfo", "PlaybackState"
- # Exclude generic types like "Any", "Union", "Optional", etc.
- excluded_types = {"Any", "Union", "Optional", "List", "Dict", "Tuple", "Set"}
- if type_hint.isidentifier() and type_hint[0].isupper() and type_hint not in excluded_types:
- # Create a schema reference for this type
- if type_hint not in definitions:
- definitions[type_hint] = {"type": "object"}
- return {"$ref": f"#/components/schemas/{type_hint}"}
-
- # If it's "Any", return generic object without creating a schema
- if type_hint == "Any":
- return {"type": "object"}
-
- # For complex type expressions like "str | None", "list[str]", return generic object
- return {"type": "object"}
-
- # Handle None type
- if type_hint is NoneType or type_hint is type(None):
- return {"type": "null"}
-
- # Handle internal Player model - replace with external PlayerState
- if hasattr(type_hint, "__name__") and type_hint.__name__ == "Player":
- # Check if this is the internal Player (from music_assistant.models.player)
- if (
- hasattr(type_hint, "__module__")
- and type_hint.__module__ == "music_assistant.models.player"
- ):
- # Replace with PlayerState from music_assistant_models
- return _get_type_schema(PlayerState, definitions)
-
- # Handle Union types (including Optional)
- origin = get_origin(type_hint)
- if origin is Union or origin is UnionType:
- args = get_args(type_hint)
- # Check if it's Optional (Union with None)
- non_none_args = [arg for arg in args if arg not in (NoneType, type(None))]
- if (len(non_none_args) == 1 and NoneType in args) or type(None) in args:
- # It's Optional[T], make it nullable
- schema = _get_type_schema(non_none_args[0], definitions)
- schema["nullable"] = True
- return schema
- # It's a union of multiple types
- return {"oneOf": [_get_type_schema(arg, definitions) for arg in args]}
-
- # Handle UniqueList (treat as array)
- if hasattr(type_hint, "__name__") and type_hint.__name__ == "UniqueList":
- args = get_args(type_hint)
- if args:
- return {"type": "array", "items": _get_type_schema(args[0], definitions)}
- return {"type": "array", "items": {}}
-
- # Handle Sequence types (from collections.abc or typing)
- if origin is collections.abc.Sequence or (
- hasattr(origin, "__name__") and origin.__name__ == "Sequence"
- ):
- args = get_args(type_hint)
- if args:
- return {"type": "array", "items": _get_type_schema(args[0], definitions)}
- return {"type": "array", "items": {}}
-
- # Handle set/frozenset types
- if origin in (set, frozenset):
- args = get_args(type_hint)
- if args:
- return {"type": "array", "items": _get_type_schema(args[0], definitions)}
- return {"type": "array", "items": {}}
-
- # Handle list/tuple types
- if origin in (list, tuple):
- args = get_args(type_hint)
- if args:
- return {"type": "array", "items": _get_type_schema(args[0], definitions)}
- return {"type": "array", "items": {}}
-
- # Handle dict types
- if origin is dict:
- args = get_args(type_hint)
- if len(args) == 2:
- return {
- "type": "object",
- "additionalProperties": _get_type_schema(args[1], definitions),
- }
- return {"type": "object", "additionalProperties": True}
-
- # Handle Enum types - add them to definitions as explorable objects
- if inspect.isclass(type_hint) and issubclass(type_hint, Enum):
- enum_name = type_hint.__name__
- if enum_name not in definitions:
- enum_values = [item.value for item in type_hint]
- enum_type = type(enum_values[0]).__name__ if enum_values else "string"
- openapi_type = {
- "str": "string",
- "int": "integer",
- "float": "number",
- "bool": "boolean",
- }.get(enum_type, "string")
-
- # Create a detailed enum definition with descriptions
- enum_values_str = ", ".join(str(v) for v in enum_values)
- definitions[enum_name] = {
- "type": openapi_type,
- "enum": enum_values,
- "description": f"Enum: {enum_name}. Possible values: {enum_values_str}",
- }
- return {"$ref": f"#/components/schemas/{enum_name}"}
-
- # Handle datetime
- if type_hint is datetime:
- return {"type": "string", "format": "date-time"}
-
- # Handle primitive types - check both exact type and type name
- if type_hint is str or (hasattr(type_hint, "__name__") and type_hint.__name__ == "str"):
- return {"type": "string"}
- if type_hint is int or (hasattr(type_hint, "__name__") and type_hint.__name__ == "int"):
- return {"type": "integer"}
- if type_hint is float or (hasattr(type_hint, "__name__") and type_hint.__name__ == "float"):
- return {"type": "number"}
- if type_hint is bool or (hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"):
- return {"type": "boolean"}
-
- # Handle complex types (dataclasses, models)
- # Check for __annotations__ or if it's a class (not already handled above)
- if hasattr(type_hint, "__annotations__") or (
- inspect.isclass(type_hint) and not issubclass(type_hint, (str, int, float, bool, Enum))
- ):
- type_name = getattr(type_hint, "__name__", str(type_hint))
- # Add to definitions if not already there
- if type_name not in definitions:
- properties = {}
- required = []
-
- # Check if this is a dataclass with fields
- if hasattr(type_hint, "__dataclass_fields__"):
- # Resolve type hints to handle forward references from __future__ annotations
- try:
- resolved_hints = get_type_hints(type_hint)
- except Exception:
- resolved_hints = {}
-
- # Use dataclass fields to get proper info including defaults and metadata
- for field_name, field_info in type_hint.__dataclass_fields__.items():
- # Skip fields marked with serialize="omit" in metadata
- if field_info.metadata:
- # Check for mashumaro field_options
- if "serialize" in field_info.metadata:
- if field_info.metadata["serialize"] == "omit":
- continue
-
- # Use resolved type hint if available, otherwise fall back to field type
- field_type = resolved_hints.get(field_name, field_info.type)
- field_schema = _get_type_schema(field_type, definitions)
-
- # Add default value if present
- if field_info.default is not MISSING:
- field_schema["default"] = field_info.default
- elif (
- hasattr(field_info, "default_factory")
- and field_info.default_factory is not MISSING
- ):
- # Has a default factory - don't add anything, just skip
- pass
-
- properties[field_name] = field_schema
-
- # Check if field is required (not Optional and no default)
- has_default = field_info.default is not MISSING or (
- hasattr(field_info, "default_factory")
- and field_info.default_factory is not MISSING
- )
- is_optional = get_origin(field_type) in (
- Union,
- UnionType,
- ) and NoneType in get_args(field_type)
- if not has_default and not is_optional:
- required.append(field_name)
- elif hasattr(type_hint, "__annotations__"):
- # Fallback for non-dataclass types with annotations
- for field_name, field_type in type_hint.__annotations__.items():
- properties[field_name] = _get_type_schema(field_type, definitions)
- # Check if field is required (not Optional)
- if not (
- get_origin(field_type) in (Union, UnionType)
- and NoneType in get_args(field_type)
- ):
- required.append(field_name)
- else:
- # Class without dataclass fields or annotations - treat as generic object
- pass # Will create empty properties
-
- definitions[type_name] = {
- "type": "object",
- "properties": properties,
- }
- if required:
- definitions[type_name]["required"] = required
-
- return {"$ref": f"#/components/schemas/{type_name}"}
-
- # Handle Any
- if type_hint is Any:
- return {"type": "object"}
-
- # Fallback - for types we don't recognize, at least return a generic object type
- return {"type": "object"}
-
-
-def _parse_docstring( # noqa: PLR0915
- func: Callable[..., Any],
-) -> tuple[str, str, dict[str, str]]:
- """Parse docstring to extract summary, description and parameter descriptions.
-
- Returns:
- Tuple of (short_summary, full_description, param_descriptions)
-
- Handles multiple docstring formats:
- - reStructuredText (:param name: description)
- - Google style (Args: section)
- - NumPy style (Parameters section)
- """
- docstring = inspect.getdoc(func)
- if not docstring:
- return "", "", {}
-
- lines = docstring.split("\n")
- description_lines = []
- param_descriptions = {}
- current_section = "description"
- current_param = None
-
- for line in lines:
- stripped = line.strip()
-
- # Check for section headers
- if stripped.lower() in ("args:", "arguments:", "parameters:", "params:"):
- current_section = "params"
- current_param = None
- continue
- if stripped.lower() in (
- "returns:",
- "return:",
- "yields:",
- "raises:",
- "raises",
- "examples:",
- "example:",
- "note:",
- "notes:",
- "see also:",
- "warning:",
- "warnings:",
- ):
- current_section = "other"
- current_param = None
- continue
-
- # Parse :param style
- if stripped.startswith(":param "):
- current_section = "params"
- parts = stripped[7:].split(":", 1)
- if len(parts) == 2:
- current_param = parts[0].strip()
- desc = parts[1].strip()
- if desc:
- param_descriptions[current_param] = desc
- continue
-
- if stripped.startswith((":type ", ":rtype", ":return")):
- current_section = "other"
- current_param = None
- continue
-
- # Detect bullet-style params even without explicit section header
- # Format: "- param_name: description"
- if stripped.startswith("- ") and ":" in stripped:
- # This is likely a bullet-style parameter
- current_section = "params"
- content = stripped[2:] # Remove "- "
- parts = content.split(":", 1)
- param_name = parts[0].strip()
- desc_part = parts[1].strip() if len(parts) > 1 else ""
- if param_name and not param_name.startswith(("return", "yield", "raise")):
- current_param = param_name
- if desc_part:
- param_descriptions[current_param] = desc_part
- continue
-
- # In params section, detect param lines (indented or starting with name)
- if current_section == "params" and stripped:
- # Google/NumPy style: "param_name: description" or "param_name (type): description"
- if ":" in stripped and not stripped.startswith(" "):
- # Likely a parameter definition
- if "(" in stripped and ")" in stripped:
- # Format: param_name (type): description
- param_part = stripped.split(":")[0]
- param_name = param_part.split("(")[0].strip()
- desc_part = ":".join(stripped.split(":")[1:]).strip()
- else:
- # Format: param_name: description
- parts = stripped.split(":", 1)
- param_name = parts[0].strip()
- desc_part = parts[1].strip() if len(parts) > 1 else ""
-
- if param_name and not param_name.startswith(("return", "yield", "raise")):
- current_param = param_name
- if desc_part:
- param_descriptions[current_param] = desc_part
- elif current_param and stripped:
- # Continuation of previous parameter description
- param_descriptions[current_param] = (
- param_descriptions.get(current_param, "") + " " + stripped
- ).strip()
- continue
-
- # Collect description lines (only before params/returns sections)
- if current_section == "description" and stripped:
- description_lines.append(stripped)
- elif current_section == "description" and not stripped and description_lines:
- # Empty line in description - keep it for paragraph breaks
- description_lines.append("")
-
- # Join description lines, removing excessive empty lines
- description = "\n".join(description_lines).strip()
- # Collapse multiple empty lines into one
- while "\n\n\n" in description:
- description = description.replace("\n\n\n", "\n\n")
-
- # Extract first sentence/line as summary
- summary = ""
- if description:
- # Get first line or first sentence (whichever is shorter)
- first_line = description.split("\n")[0]
- # Try to get first sentence (ending with .)
- summary = first_line.split(".")[0] + "." if "." in first_line else first_line
-
- return summary, description, param_descriptions
-
-
-def generate_openapi_spec(
- command_handlers: dict[str, APICommandHandler],
- server_url: str = "http://localhost:8095",
- version: str = "1.0.0",
-) -> dict[str, Any]:
- """Generate simplified OpenAPI 3.0 specification focusing on data models.
-
- This spec documents the single /api endpoint and all data models/schemas.
- For detailed command documentation, see the Commands Reference page.
- """
- definitions: dict[str, Any] = {}
-
- # Build all schemas from command handlers (this populates definitions)
- for handler in command_handlers.values():
- # Build parameter schemas
- for param_name in handler.signature.parameters:
- if param_name == "self":
- continue
- # Skip return_type parameter (used only for type hints)
- if param_name == "return_type":
- continue
- param_type = handler.type_hints.get(param_name, Any)
- # Skip Any types as they don't provide useful schema information
- if param_type is not Any and str(param_type) != "typing.Any":
- _get_type_schema(param_type, definitions)
-
- # Build return type schema
- return_type = handler.type_hints.get("return", Any)
- # Skip Any types as they don't provide useful schema information
- if return_type is not Any and str(return_type) != "typing.Any":
- _get_type_schema(return_type, definitions)
-
- # Build a single /api endpoint with generic request/response
- paths = {
- "/api": {
- "post": {
- "summary": "Execute API command",
- "description": (
- "Execute any Music Assistant API command.\n\n"
- "See the **Commands Reference** page for a complete list of available "
- "commands with examples."
- ),
- "operationId": "execute_command",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": ["command"],
- "properties": {
- "command": {
- "type": "string",
- "description": (
- "The command to execute (e.g., 'players/all')"
- ),
- "example": "players/all",
- },
- "args": {
- "type": "object",
- "description": "Command arguments (varies by command)",
- "additionalProperties": True,
- "example": {},
- },
- },
- },
- "examples": {
- "get_players": {
- "summary": "Get all players",
- "value": {"command": "players/all", "args": {}},
- },
- "play_media": {
- "summary": "Play media on a player",
- "value": {
- "command": "players/cmd/play",
- "args": {"player_id": "player123"},
- },
- },
- },
- }
- },
- },
- "responses": {
- "200": {
- "description": "Successful command execution",
- "content": {
- "application/json": {
- "schema": {"description": "Command result (varies by command)"}
- }
- },
- },
- "400": {"description": "Bad request - invalid command or parameters"},
- "500": {"description": "Internal server error"},
- },
- }
- }
- }
-
- # Build OpenAPI spec
- return {
- "openapi": "3.0.0",
- "info": {
- "title": "Music Assistant API",
- "version": version,
- "description": (
- "Music Assistant API provides control over your music library, "
- "players, and playback.\n\n"
- "This specification documents the API structure and data models. "
- "For a complete list of available commands with examples, "
- "see the Commands Reference page."
- ),
- "contact": {
- "name": "Music Assistant",
- "url": "https://music-assistant.io",
- },
- },
- "servers": [{"url": server_url, "description": "Music Assistant Server"}],
- "paths": paths,
- "components": {"schemas": definitions},
- }
-
-
-def _split_union_type(type_str: str) -> list[str]:
- """Split a union type on | but respect brackets and parentheses.
-
- This ensures that list[A | B] and (A | B) are not split at the inner |.
- """
- parts = []
- current_part = ""
- bracket_depth = 0
- paren_depth = 0
- i = 0
- while i < len(type_str):
- char = type_str[i]
- if char == "[":
- bracket_depth += 1
- current_part += char
- elif char == "]":
- bracket_depth -= 1
- current_part += char
- elif char == "(":
- paren_depth += 1
- current_part += char
- elif char == ")":
- paren_depth -= 1
- current_part += char
- elif char == "|" and bracket_depth == 0 and paren_depth == 0:
- # Check if this is a union separator (has space before and after)
- if (
- i > 0
- and i < len(type_str) - 1
- and type_str[i - 1] == " "
- and type_str[i + 1] == " "
- ):
- parts.append(current_part.strip())
- current_part = ""
- i += 1 # Skip the space after |, the loop will handle incrementing i
- else:
- current_part += char
- else:
- current_part += char
- i += 1
- if current_part.strip():
- parts.append(current_part.strip())
- return parts
-
-
-def _python_type_to_json_type(type_str: str, _depth: int = 0) -> str:
- """Convert Python type string to JSON/JavaScript type string.
-
- Args:
- type_str: The type string to convert
- _depth: Internal recursion depth tracker (do not set manually)
- """
- import re # noqa: PLC0415
-
- # Prevent infinite recursion
- if _depth > 50:
- return "any"
-
- # Remove typing module prefix and class markers
- type_str = type_str.replace("typing.", "").replace("<class '", "").replace("'>", "")
-
- # Remove module paths from type names (e.g., "music_assistant.models.Artist" -> "Artist")
- type_str = re.sub(r"[\w.]+\.(\w+)", r"\1", type_str)
-
- # Map Python types to JSON types
- type_mappings = {
- "str": "string",
- "int": "integer",
- "float": "number",
- "bool": "boolean",
- "dict": "object",
- "Dict": "object",
- "None": "null",
- "NoneType": "null",
- }
-
- # Check for List/list/UniqueList with type parameter BEFORE checking for union types
- # This is important because list[A | B] contains " | " but should be handled as a list first
- # We need to match list[...] where the brackets are balanced
- if type_str.startswith(("list[", "List[", "UniqueList[")): # codespell:ignore
- # Find the matching closing bracket
- bracket_count = 0
- start_idx = type_str.index("[") + 1
- end_idx = -1
- for i in range(start_idx, len(type_str)):
- if type_str[i] == "[":
- bracket_count += 1
- elif type_str[i] == "]":
- if bracket_count == 0:
- end_idx = i
- break
- bracket_count -= 1
-
- # Check if this is a complete list type (ends with the closing bracket)
- if end_idx == len(type_str) - 1:
- inner_type = type_str[start_idx:end_idx].strip()
- # Recursively convert the inner type
- inner_json_type = _python_type_to_json_type(inner_type, _depth + 1)
- # For list[A | B], wrap in parentheses to keep it as one unit
- # This prevents "Array of A | B" from being split into separate union parts
- if " | " in inner_json_type:
- return f"Array of ({inner_json_type})"
- return f"Array of {inner_json_type}"
-
- # Handle Union types by splitting on | and recursively processing each part
- if " | " in type_str:
- # Use helper to split on | but respect brackets
- parts = _split_union_type(type_str)
-
- # Filter out None types
- parts = [part for part in parts if part != "None"]
-
- # If splitting didn't help (only one part or same as input), avoid infinite recursion
- if not parts or (len(parts) == 1 and parts[0] == type_str):
- # Can't split further, return as-is or "any"
- return type_str if parts else "any"
-
- if parts:
- converted_parts = [_python_type_to_json_type(part, _depth + 1) for part in parts]
- # Remove duplicates while preserving order
- seen = set()
- unique_parts = []
- for part in converted_parts:
- if part not in seen:
- seen.add(part)
- unique_parts.append(part)
- return " | ".join(unique_parts)
- return "any"
-
- # Check for Union/Optional types with brackets
- if "Union[" in type_str or "Optional[" in type_str:
- # Extract content from Union[...] or Optional[...]
- union_match = re.search(r"(?:Union|Optional)\[([^\]]+)\]", type_str)
- if union_match:
- inner = union_match.group(1)
- # Recursively process the union content
- return _python_type_to_json_type(inner, _depth + 1)
-
- # Direct mapping for basic types
- for py_type, json_type in type_mappings.items():
- if type_str == py_type:
- return json_type
-
- # Check if it's a complex type (starts with capital letter)
- complex_match = re.search(r"^([A-Z][a-zA-Z0-9_]*)$", type_str)
- if complex_match:
- return complex_match.group(1)
-
- # Default to the original string if no mapping found
- return type_str
-
-
-def _make_type_links(type_str: str, server_url: str, as_list: bool = False) -> str:
- """Convert type string to HTML with links to schemas reference for complex types.
-
- Args:
- type_str: The type string to convert
- server_url: Base server URL for building links
- as_list: If True and type contains |, format as "Any of:" bullet list
- """
- import re # noqa: PLC0415
- from re import Match # noqa: PLC0415
-
- # Find all complex types (capitalized words that aren't basic types)
- def replace_type(match: Match[str]) -> str:
- type_name = match.group(0)
- # Check if it's a complex type (starts with capital letter)
- # Exclude basic types and "Array" (which is used in "Array of Type")
- excluded = {"Union", "Optional", "List", "Dict", "Array"}
- if type_name[0].isupper() and type_name not in excluded:
- # Create link to our schemas reference page
- schema_url = f"{server_url}/api-docs/schemas#schema-{type_name}"
- return f'<a href="{schema_url}" class="type-link">{type_name}</a>'
- return type_name
-
- # If it's a union type with multiple options and as_list is True, format as bullet list
- if as_list and " | " in type_str:
- # Use the bracket/parenthesis-aware splitter
- parts = _split_union_type(type_str)
- # Only use list format if there are 3+ options
- if len(parts) >= 3:
- html = '<div class="type-union"><span class="type-union-label">Any of:</span><ul>'
- for part in parts:
- linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part)
- html += f"<li>{linked_part}</li>"
- html += "</ul></div>"
- return html
-
- # Replace complex type names with links
- result: str = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, type_str)
- return result
-
-
-def generate_commands_reference( # noqa: PLR0915
- command_handlers: dict[str, APICommandHandler],
- server_url: str = "http://localhost:8095",
-) -> str:
- """Generate HTML commands reference page with all available commands."""
- import json # noqa: PLC0415
-
- # Group commands by category
- categories: dict[str, list[tuple[str, APICommandHandler]]] = {}
- for command, handler in sorted(command_handlers.items()):
- category = command.split("/")[0] if "/" in command else "general"
- if category not in categories:
- categories[category] = []
- categories[category].append((command, handler))
-
- html = """<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Music Assistant API - Commands Reference</title>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
- Ubuntu, Cantarell, sans-serif;
- background: #f5f5f5;
- line-height: 1.6;
- }
- .header {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 1.5rem 2rem;
- text-align: center;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
- }
- .header h1 {
- font-size: 1.8em;
- margin-bottom: 0.3rem;
- font-weight: 600;
- }
- .header p {
- font-size: 0.95em;
- opacity: 0.9;
- }
- .nav-container {
- background: white;
- padding: 1rem 2rem;
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
- position: sticky;
- top: 0;
- z-index: 100;
- display: flex;
- flex-direction: column;
- gap: 1rem;
- }
- .search-box input {
- width: 100%;
- max-width: 600px;
- padding: 0.6rem 1rem;
- font-size: 0.95em;
- border: 2px solid #ddd;
- border-radius: 8px;
- display: block;
- margin: 0 auto;
- }
- .search-box input:focus {
- outline: none;
- border-color: #667eea;
- }
- .quick-nav {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- justify-content: center;
- padding-top: 0.5rem;
- border-top: 1px solid #eee;
- }
- .quick-nav a {
- padding: 0.4rem 1rem;
- background: #f8f9fa;
- color: #667eea;
- text-decoration: none;
- border-radius: 6px;
- font-size: 0.9em;
- transition: all 0.2s;
- }
- .quick-nav a:hover {
- background: #667eea;
- color: white;
- }
- .container {
- max-width: 1200px;
- margin: 2rem auto;
- padding: 0 2rem;
- }
- .category {
- background: white;
- margin-bottom: 2rem;
- border-radius: 12px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.08);
- overflow: hidden;
- }
- .category-header {
- background: #667eea;
- color: white;
- padding: 1rem 1.5rem;
- font-size: 1.2em;
- font-weight: 600;
- cursor: pointer;
- user-select: none;
- }
- .category-header:hover {
- background: #5568d3;
- }
- .command {
- border-bottom: 1px solid #eee;
- }
- .command:last-child {
- border-bottom: none;
- }
- .command-header {
- padding: 1rem 1.5rem;
- cursor: pointer;
- user-select: none;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: background 0.2s;
- }
- .command-header:hover {
- background: #f8f9fa;
- }
- .command-title {
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
- flex: 1;
- }
- .command-name {
- font-size: 1.1em;
- font-weight: 600;
- color: #667eea;
- font-family: 'Monaco', 'Courier New', monospace;
- }
- .command-summary {
- font-size: 0.9em;
- color: #888;
- }
- .command-expand-icon {
- color: #667eea;
- font-size: 1.2em;
- transition: transform 0.3s;
- }
- .command-expand-icon.expanded {
- transform: rotate(180deg);
- }
- .command-details {
- padding: 0 1.5rem 1.5rem 1.5rem;
- display: none;
- }
- .command-details.show {
- display: block;
- }
- .command-description {
- color: #666;
- margin-bottom: 1rem;
- }
- .return-type {
- background: #e8f5e9;
- padding: 0.5rem 1rem;
- margin: 1rem 0;
- border-radius: 6px;
- border-left: 3px solid #4caf50;
- }
- .return-type-label {
- font-weight: 600;
- color: #2e7d32;
- margin-right: 0.5rem;
- }
- .return-type-value {
- font-family: 'Monaco', 'Courier New', monospace;
- color: #2e7d32;
- }
- .params-section {
- margin: 1rem 0;
- }
- .params-title {
- font-weight: 600;
- color: #333;
- margin-bottom: 0.5rem;
- }
- .param {
- background: #f8f9fa;
- padding: 0.5rem 1rem;
- margin: 0.5rem 0;
- border-radius: 6px;
- border-left: 3px solid #667eea;
- }
- .param-name {
- font-family: 'Monaco', 'Courier New', monospace;
- color: #667eea;
- font-weight: 600;
- }
- .param-required {
- color: #e74c3c;
- font-size: 0.8em;
- font-weight: 600;
- margin-left: 0.5rem;
- }
- .param-type {
- color: #888;
- font-size: 0.9em;
- margin-left: 0.5rem;
- }
- .param-description {
- color: #666;
- margin-top: 0.25rem;
- }
- .example {
- background: #2d2d2d;
- color: #f8f8f2;
- padding: 1rem;
- border-radius: 8px;
- margin: 1rem 0;
- overflow-x: auto;
- position: relative;
- }
- .example-title {
- font-weight: 600;
- color: #333;
- margin-bottom: 0.5rem;
- }
- .example pre {
- margin: 0;
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.9em;
- }
- .copy-btn {
- position: absolute;
- top: 0.5rem;
- right: 0.5rem;
- background: #667eea;
- color: white;
- border: none;
- padding: 0.4rem 0.8rem;
- border-radius: 4px;
- cursor: pointer;
- font-size: 0.8em;
- }
- .copy-btn:hover {
- background: #5568d3;
- }
- .hidden {
- display: none;
- }
- .tabs {
- margin: 1rem 0;
- }
- .tab-buttons {
- display: flex;
- gap: 0.5rem;
- border-bottom: 2px solid #ddd;
- margin-bottom: 1rem;
- }
- .tab-btn {
- background: none;
- border: none;
- padding: 0.8rem 1.5rem;
- font-size: 1em;
- cursor: pointer;
- color: #666;
- border-bottom: 3px solid transparent;
- transition: all 0.3s;
- }
- .tab-btn:hover {
- color: #667eea;
- }
- .tab-btn.active {
- color: #667eea;
- border-bottom-color: #667eea;
- }
- .tab-content {
- display: none;
- }
- .tab-content.active {
- display: block;
- }
- .try-it-section {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- }
- .json-input {
- width: 100%;
- min-height: 150px;
- padding: 1rem;
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.9em;
- border: 2px solid #ddd;
- border-radius: 8px;
- background: #2d2d2d;
- color: #f8f8f2;
- resize: vertical;
- }
- .json-input:focus {
- outline: none;
- border-color: #667eea;
- }
- .try-btn {
- align-self: flex-start;
- background: #667eea;
- color: white;
- border: none;
- padding: 0.8rem 2rem;
- border-radius: 8px;
- font-size: 1em;
- cursor: pointer;
- transition: background 0.3s;
- }
- .try-btn:hover {
- background: #5568d3;
- }
- .try-btn:disabled {
- background: #ccc;
- cursor: not-allowed;
- }
- .response-output {
- background: #2d2d2d;
- color: #f8f8f2;
- padding: 1rem;
- border-radius: 8px;
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.9em;
- min-height: 100px;
- white-space: pre-wrap;
- word-wrap: break-word;
- display: none;
- }
- .response-output.show {
- display: block;
- }
- .response-output.error {
- background: #ffebee;
- color: #c62828;
- }
- .response-output.success {
- background: #e8f5e9;
- color: #2e7d32;
- }
- .type-link {
- color: #667eea;
- text-decoration: none;
- border-bottom: 1px dashed #667eea;
- transition: all 0.2s;
- }
- .type-link:hover {
- color: #5568d3;
- border-bottom-color: #5568d3;
- }
- .type-union {
- margin-top: 0.5rem;
- }
- .type-union-label {
- font-weight: 600;
- color: #4a5568;
- display: block;
- margin-bottom: 0.25rem;
- }
- .type-union ul {
- margin: 0.25rem 0 0 0;
- padding-left: 1.5rem;
- list-style-type: disc;
- }
- .type-union li {
- margin: 0.25rem 0;
- color: #2d3748;
- }
- .param-type-union {
- display: block;
- margin-top: 0.25rem;
- }
- </style>
-</head>
-<body>
- <div class="header">
- <h1>Commands Reference</h1>
- <p>Complete list of Music Assistant API commands</p>
- </div>
-
- <div class="nav-container">
- <div class="search-box">
- <input type="text" id="search" placeholder="Search commands..." />
- </div>
- <div class="quick-nav">
-"""
-
- # Add quick navigation links
- for category in sorted(categories.keys()):
- category_display = category.replace("_", " ").title()
- html += f' <a href="#{category}">{category_display}</a>\n'
-
- html += """ </div>
- </div>
-
- <div class="container">
-"""
-
- for category, commands in sorted(categories.items()):
- category_display = category.replace("_", " ").title()
- html += f' <div class="category" id="{category}" data-category="{category}">\n'
- html += f' <div class="category-header">{category_display}</div>\n'
- html += ' <div class="category-content">\n'
-
- for command, handler in commands:
- # Parse docstring
- summary, description, param_descriptions = _parse_docstring(handler.target)
-
- # Get return type
- return_type = handler.type_hints.get("return", Any)
- return_type_str = _python_type_to_json_type(str(return_type))
-
- html += f' <div class="command" data-command="{command}">\n'
- html += (
- ' <div class="command-header" onclick="toggleCommand(this)">\n'
- )
- html += ' <div class="command-title">\n'
- html += f' <div class="command-name">{command}</div>\n'
- if summary:
- summary_escaped = summary.replace("<", "<").replace(">", ">")
- html += (
- f' <div class="command-summary">'
- f"{summary_escaped}</div>\n"
- )
- html += " </div>\n"
- html += ' <div class="command-expand-icon">▼</div>\n'
- html += " </div>\n"
-
- # Command details (collapsed by default)
- html += ' <div class="command-details">\n'
-
- if description and description != summary:
- desc_escaped = description.replace("<", "<").replace(">", ">")
- html += (
- f' <div class="command-description">'
- f"{desc_escaped}</div>\n"
- )
-
- # Return type with links
- return_type_html = _make_type_links(return_type_str, server_url)
- html += ' <div class="return-type">\n'
- html += ' <span class="return-type-label">Returns:</span>\n'
- html += f' <span class="return-type-value">{return_type_html}</span>\n' # noqa: E501
- html += " </div>\n"
-
- # Parameters
- params = []
- for param_name, param in handler.signature.parameters.items():
- if param_name == "self":
- continue
- # Skip return_type parameter (used only for type hints)
- if param_name == "return_type":
- continue
- is_required = param.default is inspect.Parameter.empty
- param_type = handler.type_hints.get(param_name, Any)
- type_str = str(param_type)
- json_type_str = _python_type_to_json_type(type_str)
- param_desc = param_descriptions.get(param_name, "")
- params.append((param_name, is_required, json_type_str, param_desc))
-
- if params:
- html += ' <div class="params-section">\n'
- html += ' <div class="params-title">Parameters:</div>\n'
- for param_name, is_required, type_str, param_desc in params:
- # Convert type to HTML with links (use list format for unions)
- type_html = _make_type_links(type_str, server_url, as_list=True)
- html += ' <div class="param">\n'
- html += (
- f' <span class="param-name">'
- f"{param_name}</span>\n"
- )
- if is_required:
- html += (
- ' <span class="param-required">'
- "REQUIRED</span>\n"
- )
- # If it's a list format, display it differently
- if "<ul>" in type_html:
- html += (
- ' <div class="param-type-union">'
- f"{type_html}</div>\n"
- )
- else:
- html += (
- f' <span class="param-type">'
- f"{type_html}</span>\n"
- )
- if param_desc:
- html += (
- f' <div class="param-description">'
- f"{param_desc}</div>\n"
- )
- html += " </div>\n"
- html += " </div>\n"
-
- # Build example curl command with JSON types
- example_args: dict[str, Any] = {}
- for param_name, is_required, type_str, _ in params:
- # Include optional params if few params
- if is_required or len(params) <= 2:
- if type_str == "string":
- example_args[param_name] = "example_value"
- elif type_str == "integer":
- example_args[param_name] = 0
- elif type_str == "number":
- example_args[param_name] = 0.0
- elif type_str == "boolean":
- example_args[param_name] = True
- elif type_str == "object":
- example_args[param_name] = {}
- elif type_str == "null":
- example_args[param_name] = None
- elif type_str.startswith("Array of "):
- # Array type with item type specified (e.g., "Array of Artist")
- item_type = type_str[9:] # Remove "Array of "
- if item_type in {"string", "integer", "number", "boolean"}:
- example_args[param_name] = []
- else:
- # Complex type array
- example_args[param_name] = [
- {"_comment": f"See {item_type} schema in Swagger UI"}
- ]
- else:
- # Complex type (Artist, Player, etc.) - use placeholder object
- # Extract the primary type if it's a union (e.g., "Artist | string")
- primary_type = type_str.split(" | ")[0] if " | " in type_str else type_str
- example_args[param_name] = {
- "_comment": f"See {primary_type} schema in Swagger UI"
- }
-
- request_body: dict[str, Any] = {"command": command}
- if example_args:
- request_body["args"] = example_args
-
- curl_cmd = (
- f"curl -X POST {server_url}/api \\\n"
- ' -H "Content-Type: application/json" \\\n'
- f" -d '{json.dumps(request_body, indent=2)}'"
- )
-
- # Add tabs for curl example and try it
- html += ' <div class="tabs">\n'
- html += ' <div class="tab-buttons">\n'
- html += (
- ' <button class="tab-btn active" '
- f"onclick=\"switchTab(this, 'curl-{command.replace('/', '-')}')\">cURL</button>\n"
- )
- html += (
- ' <button class="tab-btn" '
- f"onclick=\"switchTab(this, 'tryit-{command.replace('/', '-')}')\">Try It</button>\n" # noqa: E501
- )
- html += " </div>\n"
-
- # cURL tab
- html += f' <div id="curl-{command.replace("/", "-")}" class="tab-content active">\n' # noqa: E501
- html += ' <div class="example">\n'
- html += (
- ' <button class="copy-btn" '
- 'onclick="copyCode(this)">Copy</button>\n'
- )
- html += f" <pre>{curl_cmd}</pre>\n"
- html += " </div>\n"
- html += " </div>\n"
-
- # Try It tab
- html += f' <div id="tryit-{command.replace("/", "-")}" class="tab-content">\n' # noqa: E501
- html += ' <div class="try-it-section">\n'
- # HTML-escape the JSON for the textarea
- json_str = json.dumps(request_body, indent=2)
- # Escape HTML entities
- json_str_escaped = (
- json_str.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace('"', """)
- .replace("'", "'")
- )
- html += f' <textarea class="json-input">{json_str_escaped}</textarea>\n' # noqa: E501
- html += (
- f' <button class="try-btn" '
- f"onclick=\"tryCommand(this, '{command}')\">Execute</button>\n"
- )
- html += ' <div class="response-output"></div>\n'
- html += " </div>\n"
- html += " </div>\n"
-
- html += " </div>\n"
- # Close command-details div
- html += " </div>\n"
- # Close command div
- html += " </div>\n"
-
- html += " </div>\n"
- html += " </div>\n"
-
- html += """ </div>
-
- <script>
- // Search functionality
- document.getElementById('search').addEventListener('input', function(e) {
- const searchTerm = e.target.value.toLowerCase();
- const commands = document.querySelectorAll('.command');
- const categories = document.querySelectorAll('.category');
-
- commands.forEach(command => {
- const commandName = command.dataset.command;
- const commandText = command.textContent.toLowerCase();
- if (commandName.includes(searchTerm) || commandText.includes(searchTerm)) {
- command.classList.remove('hidden');
- } else {
- command.classList.add('hidden');
- }
- });
-
- // Hide empty categories
- categories.forEach(category => {
- const visibleCommands = category.querySelectorAll('.command:not(.hidden)');
- if (visibleCommands.length === 0) {
- category.classList.add('hidden');
- } else {
- category.classList.remove('hidden');
- }
- });
- });
-
- // Toggle command details
- function toggleCommand(header) {
- const command = header.parentElement;
- const details = command.querySelector('.command-details');
- const icon = header.querySelector('.command-expand-icon');
-
- details.classList.toggle('show');
- icon.classList.toggle('expanded');
- }
-
- // Copy to clipboard
- function copyCode(button) {
- const code = button.nextElementSibling.textContent;
- navigator.clipboard.writeText(code).then(() => {
- const originalText = button.textContent;
- button.textContent = 'Copied!';
- setTimeout(() => {
- button.textContent = originalText;
- }, 2000);
- });
- }
-
- // Tab switching
- function switchTab(button, tabId) {
- const tabButtons = button.parentElement;
- const tabs = tabButtons.parentElement;
-
- // Remove active class from all buttons and tabs
- tabButtons.querySelectorAll('.tab-btn').forEach(btn => {
- btn.classList.remove('active');
- });
- tabs.querySelectorAll('.tab-content').forEach(content => {
- content.classList.remove('active');
- });
-
- // Add active class to clicked button and corresponding tab
- button.classList.add('active');
- document.getElementById(tabId).classList.add('active');
- }
-
- // Try command functionality
- async function tryCommand(button, commandName) {
- const section = button.parentElement;
- const textarea = section.querySelector('.json-input');
- const output = section.querySelector('.response-output');
-
- // Disable button while processing
- button.disabled = true;
- button.textContent = 'Executing...';
-
- // Clear previous output
- output.className = 'response-output show';
- output.textContent = 'Loading...';
-
- try {
- // Parse JSON from textarea
- let requestBody;
- try {
- requestBody = JSON.parse(textarea.value);
- } catch (e) {
- throw new Error('Invalid JSON: ' + e.message);
- }
-
- // Make API request
- const response = await fetch('/api', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody)
- });
-
- let result;
- const contentType = response.headers.get('content-type');
- if (contentType && contentType.includes('application/json')) {
- result = await response.json();
- } else {
- const text = await response.text();
- result = { error: text };
- }
-
- // Display result
- if (response.ok) {
- output.className = 'response-output show success';
- output.textContent = 'Success!\\n\\n' + JSON.stringify(result, null, 2);
- } else {
- output.className = 'response-output show error';
- // Try to extract a meaningful error message
- let errorMsg = 'Request failed';
- if (result.error) {
- errorMsg = result.error;
- } else if (result.message) {
- errorMsg = result.message;
- } else if (typeof result === 'string') {
- errorMsg = result;
- } else {
- errorMsg = JSON.stringify(result, null, 2);
- }
- output.textContent = 'Error: ' + errorMsg;
- }
- } catch (error) {
- output.className = 'response-output show error';
- // Provide more user-friendly error messages
- if (error.message.includes('Invalid JSON')) {
- output.textContent = 'JSON Syntax Error: Please check your request format. '
- + error.message;
- } else if (error.message.includes('Failed to fetch')) {
- output.textContent = 'Connection Error: Unable to reach the API server. '
- + 'Please check if the server is running.';
- } else {
- output.textContent = 'Error: ' + error.message;
- }
- } finally {
- button.disabled = false;
- button.textContent = 'Execute';
- }
- }
- </script>
-</body>
-</html>
-"""
-
- return html
-
-
-def generate_schemas_reference( # noqa: PLR0915
- command_handlers: dict[str, APICommandHandler],
-) -> str:
- """Generate HTML schemas reference page with all data models."""
- # Collect all unique schemas from commands
- schemas: dict[str, Any] = {}
-
- for handler in command_handlers.values():
- # Collect schemas from parameters
- for param_name in handler.signature.parameters:
- if param_name == "self":
- continue
- # Skip return_type parameter (used only for type hints)
- if param_name == "return_type":
- continue
- param_type = handler.type_hints.get(param_name, Any)
- if param_type is not Any and str(param_type) != "typing.Any":
- _get_type_schema(param_type, schemas)
-
- # Collect schemas from return type
- return_type = handler.type_hints.get("return", Any)
- if return_type is not Any and str(return_type) != "typing.Any":
- _get_type_schema(return_type, schemas)
-
- # Build HTML
- html = """<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Music Assistant API - Schemas Reference</title>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
- Ubuntu, Cantarell, sans-serif;
- background: #f5f5f5;
- line-height: 1.6;
- }
- .header {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 1.5rem 2rem;
- text-align: center;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
- }
- .header h1 {
- font-size: 1.8em;
- margin-bottom: 0.3rem;
- font-weight: 600;
- }
- .header p {
- font-size: 0.95em;
- opacity: 0.9;
- }
- .nav-container {
- background: white;
- padding: 1rem 2rem;
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
- position: sticky;
- top: 0;
- z-index: 100;
- }
- .search-box input {
- width: 100%;
- max-width: 600px;
- padding: 0.6rem 1rem;
- font-size: 0.95em;
- border: 2px solid #ddd;
- border-radius: 8px;
- display: block;
- margin: 0 auto;
- }
- .search-box input:focus {
- outline: none;
- border-color: #667eea;
- }
- .container {
- max-width: 1200px;
- margin: 2rem auto;
- padding: 0 2rem;
- }
- .schema {
- background: white;
- margin-bottom: 1.5rem;
- border-radius: 12px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.08);
- overflow: hidden;
- scroll-margin-top: 100px;
- }
- .schema-header {
- background: #667eea;
- color: white;
- padding: 1rem 1.5rem;
- cursor: pointer;
- user-select: none;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .schema-header:hover {
- background: #5568d3;
- }
- .schema-name {
- font-size: 1.3em;
- font-weight: 600;
- font-family: 'Monaco', 'Courier New', monospace;
- }
- .schema-expand-icon {
- font-size: 1.2em;
- transition: transform 0.3s;
- }
- .schema-expand-icon.expanded {
- transform: rotate(180deg);
- }
- .schema-content {
- padding: 1.5rem;
- display: none;
- }
- .schema-content.show {
- display: block;
- }
- .schema-description {
- color: #666;
- margin-bottom: 1rem;
- font-style: italic;
- }
- .properties-section {
- margin-top: 1rem;
- }
- .properties-title {
- font-weight: 600;
- color: #333;
- margin-bottom: 0.5rem;
- font-size: 1.1em;
- }
- .property {
- background: #f8f9fa;
- padding: 0.75rem 1rem;
- margin: 0.5rem 0;
- border-radius: 6px;
- border-left: 3px solid #667eea;
- }
- .property-name {
- font-family: 'Monaco', 'Courier New', monospace;
- color: #667eea;
- font-weight: 600;
- font-size: 1em;
- }
- .property-required {
- display: inline-block;
- background: #e74c3c;
- color: white;
- padding: 0.15rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75em;
- font-weight: 600;
- margin-left: 0.5rem;
- }
- .property-optional {
- display: inline-block;
- background: #95a5a6;
- color: white;
- padding: 0.15rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75em;
- font-weight: 600;
- margin-left: 0.5rem;
- }
- .property-nullable {
- display: inline-block;
- background: #f39c12;
- color: white;
- padding: 0.15rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75em;
- font-weight: 600;
- margin-left: 0.5rem;
- }
- .property-type {
- color: #888;
- font-size: 0.9em;
- margin-left: 0.5rem;
- font-family: 'Monaco', 'Courier New', monospace;
- }
- .property-description {
- color: #666;
- margin-top: 0.25rem;
- font-size: 0.95em;
- }
- .type-link {
- color: #667eea;
- text-decoration: none;
- border-bottom: 1px dashed #667eea;
- transition: all 0.2s;
- }
- .type-link:hover {
- color: #5568d3;
- border-bottom-color: #5568d3;
- }
- .hidden {
- display: none;
- }
- .back-link {
- display: inline-block;
- margin-bottom: 1rem;
- padding: 0.5rem 1rem;
- background: #667eea;
- color: white;
- text-decoration: none;
- border-radius: 6px;
- transition: background 0.2s;
- }
- .back-link:hover {
- background: #5568d3;
- }
- .openapi-link {
- display: inline-block;
- padding: 0.5rem 1rem;
- background: #2e7d32;
- color: white;
- text-decoration: none;
- border-radius: 6px;
- transition: background 0.2s;
- }
- .openapi-link:hover {
- background: #1b5e20;
- }
- .enum-values {
- margin-top: 0.5rem;
- padding: 0.5rem;
- background: #fff;
- border-radius: 4px;
- }
- .enum-values-title {
- font-weight: 600;
- color: #555;
- font-size: 0.9em;
- margin-bottom: 0.25rem;
- }
- .enum-value {
- display: inline-block;
- padding: 0.2rem 0.5rem;
- margin: 0.2rem;
- background: #e8f5e9;
- border-radius: 4px;
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.85em;
- color: #2e7d32;
- }
- </style>
-</head>
-<body>
- <div class="header">
- <h1>Schemas Reference</h1>
- <p>Data models and types used in the Music Assistant API</p>
- </div>
-
- <div class="nav-container">
- <div class="search-box">
- <input type="text" id="search" placeholder="Search schemas..." />
- </div>
- </div>
-
- <div class="container">
- <a href="/api-docs" class="back-link">← Back to API Documentation</a>
-"""
-
- # Add each schema
- for schema_name in sorted(schemas.keys()):
- schema_def = schemas[schema_name]
- html += (
- f' <div class="schema" id="schema-{schema_name}" data-schema="{schema_name}">\n'
- )
- html += ' <div class="schema-header" onclick="toggleSchema(this)">\n'
- html += f' <div class="schema-name">{schema_name}</div>\n'
- html += ' <div class="schema-expand-icon">▼</div>\n'
- html += " </div>\n"
- html += ' <div class="schema-content">\n'
-
- # Add description if available
- if "description" in schema_def:
- desc = schema_def["description"]
- html += f' <div class="schema-description">{desc}</div>\n'
-
- # Add properties if available
- if "properties" in schema_def:
- html += ' <div class="properties-section">\n'
- html += ' <div class="properties-title">Properties:</div>\n'
-
- # Get required fields list
- required_fields = schema_def.get("required", [])
-
- for prop_name, prop_def in schema_def["properties"].items():
- html += ' <div class="property">\n'
- html += f' <span class="property-name">{prop_name}</span>\n'
-
- # Check if field is required
- is_required = prop_name in required_fields
-
- # Check if field is nullable (type is "null" or has null in anyOf/oneOf)
- is_nullable = False
- if "type" in prop_def and prop_def["type"] == "null":
- is_nullable = True
- elif "anyOf" in prop_def:
- is_nullable = any(item.get("type") == "null" for item in prop_def["anyOf"])
- elif "oneOf" in prop_def:
- is_nullable = any(item.get("type") == "null" for item in prop_def["oneOf"])
-
- # Add required/optional badge
- if is_required:
- html += (
- ' <span class="property-required">REQUIRED</span>\n'
- )
- else:
- html += (
- ' <span class="property-optional">OPTIONAL</span>\n'
- )
-
- # Add nullable badge if applicable
- if is_nullable:
- html += (
- ' <span class="property-nullable">NULLABLE</span>\n'
- )
-
- # Add type
- if "type" in prop_def:
- prop_type = prop_def["type"]
- html += (
- f' <span class="property-type">{prop_type}</span>\n'
- )
- elif "$ref" in prop_def:
- # Extract type name from $ref
- ref_type = prop_def["$ref"].split("/")[-1]
- html += (
- f' <span class="property-type">'
- f'<a href="#schema-{ref_type}" class="type-link">'
- f"{ref_type}</a></span>\n"
- )
-
- # Add description
- if "description" in prop_def:
- prop_desc = prop_def["description"]
- html += (
- f' <div class="property-description">'
- f"{prop_desc}</div>\n"
- )
-
- # Add enum values if present
- if "enum" in prop_def:
- html += ' <div class="enum-values">\n'
- html += (
- ' <div class="enum-values-title">'
- "Possible values:</div>\n"
- )
- for enum_val in prop_def["enum"]:
- html += (
- f' <span class="enum-value">'
- f"{enum_val}</span>\n"
- )
- html += " </div>\n"
-
- html += " </div>\n"
-
- html += " </div>\n"
-
- html += " </div>\n"
- html += " </div>\n"
-
- html += """
- <div style="text-align: center; margin-top: 3rem; padding: 2rem 0;">
- <a href="/api-docs/openapi.json" class="openapi-link" download>
- 📄 Download OpenAPI Spec
- </a>
- </div>
- </div>
-
- <script>
- // Search functionality
- document.getElementById('search').addEventListener('input', function(e) {
- const searchTerm = e.target.value.toLowerCase();
- const schemas = document.querySelectorAll('.schema');
-
- schemas.forEach(schema => {
- const schemaName = schema.dataset.schema;
- const schemaText = schema.textContent.toLowerCase();
- const nameMatch = schemaName.toLowerCase().includes(searchTerm);
- const textMatch = schemaText.includes(searchTerm);
- if (nameMatch || textMatch) {
- schema.classList.remove('hidden');
- } else {
- schema.classList.add('hidden');
- }
- });
- });
-
- // Toggle schema details
- function toggleSchema(header) {
- const schema = header.parentElement;
- const content = schema.querySelector('.schema-content');
- const icon = header.querySelector('.schema-expand-icon');
-
- content.classList.toggle('show');
- icon.classList.toggle('expanded');
- }
-
- // Handle deep linking - expand and scroll to schema on page load
- window.addEventListener('DOMContentLoaded', function() {
- const hash = window.location.hash;
- if (hash && hash.startsWith('#schema-')) {
- const schemaElement = document.querySelector(hash);
- if (schemaElement) {
- // Expand the schema
- const content = schemaElement.querySelector('.schema-content');
- const icon = schemaElement.querySelector('.schema-expand-icon');
- if (content && icon) {
- content.classList.add('show');
- icon.classList.add('expanded');
- }
- // Scroll to it
- setTimeout(() => {
- schemaElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- // Highlight temporarily
- schemaElement.style.transition = 'background-color 0.3s';
- schemaElement.style.backgroundColor = '#fff3cd';
- setTimeout(() => {
- schemaElement.style.backgroundColor = '';
- }, 2000);
- }, 100);
- }
- }
- });
-
- // Listen for hash changes (when user clicks a type link)
- window.addEventListener('hashchange', function() {
- const hash = window.location.hash;
- if (hash && hash.startsWith('#schema-')) {
- const schemaElement = document.querySelector(hash);
- if (schemaElement) {
- // Expand if collapsed
- const content = schemaElement.querySelector('.schema-content');
- const icon = schemaElement.querySelector('.schema-expand-icon');
- if (content && !content.classList.contains('show')) {
- content.classList.add('show');
- icon.classList.add('expanded');
- }
- // Scroll to it
- schemaElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- // Highlight temporarily
- schemaElement.style.transition = 'background-color 0.3s';
- schemaElement.style.backgroundColor = '#fff3cd';
- setTimeout(() => {
- schemaElement.style.backgroundColor = '';
- }, 2000);
- }
- }
- });
- </script>
-</body>
-</html>
-"""
-
- return html
-
-
-def generate_html_docs( # noqa: PLR0915
- command_handlers: dict[str, APICommandHandler],
- server_url: str = "http://localhost:8095",
- version: str = "1.0.0",
-) -> str:
- """Generate HTML documentation from API command handlers."""
- # Group commands by category
- categories: dict[str, list[tuple[str, APICommandHandler]]] = {}
- for command, handler in sorted(command_handlers.items()):
- category = command.split("/")[0] if "/" in command else "general"
- if category not in categories:
- categories[category] = []
- categories[category].append((command, handler))
-
- # Start building HTML
- html_parts = [
- """<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Music Assistant API Documentation</title>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, sans-serif;
- line-height: 1.6;
- color: #333;
- background: #f5f5f5;
- }
- .container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 20px;
- }
- .header {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 40px 20px;
- text-align: center;
- margin-bottom: 30px;
- border-radius: 8px;
- box-shadow: 0 4px 6px rgba(0,0,0,0.1);
- }
- .header h1 {
- font-size: 2.5em;
- margin-bottom: 10px;
- }
- .header p {
- font-size: 1.1em;
- opacity: 0.9;
- }
- .intro {
- background: white;
- padding: 30px;
- margin-bottom: 30px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }
- .intro h2 {
- color: #667eea;
- margin-bottom: 15px;
- }
- .intro h3 {
- color: #764ba2;
- margin: 20px 0 10px 0;
- }
- .intro pre {
- background: #f8f9fa;
- padding: 15px;
- border-radius: 4px;
- overflow-x: auto;
- border-left: 4px solid #667eea;
- }
- .intro code {
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.9em;
- }
- .category {
- background: white;
- margin-bottom: 30px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- overflow: hidden;
- }
- .category-header {
- background: #667eea;
- color: white;
- padding: 20px;
- font-size: 1.5em;
- font-weight: bold;
- text-transform: capitalize;
- }
- .command {
- border-bottom: 1px solid #e0e0e0;
- padding: 20px;
- }
- .command:last-child {
- border-bottom: none;
- }
- .command-name {
- font-size: 1.2em;
- font-weight: bold;
- color: #667eea;
- font-family: 'Monaco', 'Courier New', monospace;
- margin-bottom: 10px;
- }
- .command-description {
- color: #666;
- margin-bottom: 15px;
- }
- .params, .returns {
- margin-top: 15px;
- }
- .params h4, .returns h4 {
- color: #764ba2;
- margin-bottom: 10px;
- font-size: 1em;
- }
- .param {
- background: #f8f9fa;
- padding: 10px;
- margin: 5px 0;
- border-radius: 4px;
- border-left: 3px solid #667eea;
- }
- .param-name {
- font-weight: bold;
- color: #333;
- font-family: 'Monaco', 'Courier New', monospace;
- }
- .param-type {
- color: #764ba2;
- font-style: italic;
- font-size: 0.9em;
- }
- .param-required {
- color: #e74c3c;
- font-size: 0.85em;
- font-weight: bold;
- }
- .param-optional {
- color: #95a5a6;
- font-size: 0.85em;
- }
- .param-description {
- color: #666;
- margin-top: 5px;
- }
- .return-type {
- background: #f8f9fa;
- padding: 10px;
- border-radius: 4px;
- border-left: 3px solid #764ba2;
- font-family: 'Monaco', 'Courier New', monospace;
- color: #764ba2;
- }
- .nav {
- background: white;
- padding: 20px;
- margin-bottom: 30px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }
- .nav h3 {
- color: #667eea;
- margin-bottom: 15px;
- }
- .nav ul {
- list-style: none;
- }
- .nav li {
- margin: 5px 0;
- }
- .nav a {
- color: #667eea;
- text-decoration: none;
- text-transform: capitalize;
- }
- .nav a:hover {
- text-decoration: underline;
- }
- .download-link {
- display: inline-block;
- background: #667eea;
- color: white;
- padding: 10px 20px;
- border-radius: 4px;
- text-decoration: none;
- margin-top: 10px;
- }
- .download-link:hover {
- background: #764ba2;
- }
- </style>
-</head>
-<body>
- <div class="container">
- <div class="header">
- <h1>Music Assistant API Documentation</h1>
- <p>Version """,
- version,
- """</p>
- </div>
-
- <div class="intro">
- <h2>Getting Started</h2>
- <p>Music Assistant provides two ways to interact with the API:</p>
-
- <h3>🔌 WebSocket API (Recommended)</h3>
- <p>
- The WebSocket API provides full access to all commands
- and <strong>real-time event updates</strong>.
- </p>
- <ul style="margin-left: 20px; margin-top: 10px;">
- <li><strong>Endpoint:</strong> <code>ws://""",
- server_url.replace("http://", "").replace("https://", ""),
- """/ws</code></li>
- <li>
- <strong>Best for:</strong> Applications that need live
- updates and real-time communication
- </li>
- <li>
- <strong>Bonus:</strong> When connected, you automatically
- receive event messages for state changes
- </li>
- </ul>
- <p style="margin-top: 10px;"><strong>Sending commands:</strong></p>
- <pre><code>{
- "message_id": "unique-id-123",
- "command": "players/all",
- "args": {}
-}</code></pre>
- <p style="margin-top: 10px;"><strong>Receiving events:</strong></p>
- <p>
- Once connected, you will automatically receive event messages
- whenever something changes:
- </p>
- <pre><code>{
- "event": "player_updated",
- "data": {
- "player_id": "player_123",
- ...player data...
- }
-}</code></pre>
-
- <h3>🌐 REST API (Simple)</h3>
- <p>
- The REST API provides a simple HTTP interface for
- executing commands.
- </p>
- <ul style="margin-left: 20px; margin-top: 10px;">
- <li><strong>Endpoint:</strong> <code>POST """,
- server_url,
- """/api</code></li>
- <li>
- <strong>Best for:</strong> Simple, incidental commands
- without need for real-time updates
- </li>
- </ul>
- <p style="margin-top: 10px;"><strong>Example request:</strong></p>
- <pre><code>{
- "command": "players/all",
- "args": {}
-}</code></pre>
-
- <h3>📥 OpenAPI Specification</h3>
- <p>Download the OpenAPI 3.0 specification for automated client generation:</p>
- <a href="/openapi.json" class="download-link">Download openapi.json</a>
-
- <h3>🚀 Interactive API Explorers</h3>
- <p>
- Try out the API interactively with our API explorers.
- Test endpoints, see live responses, and explore the full API:
- </p>
- <div style="margin-top: 15px;">
- <a href="/api-explorer" class="download-link" style="margin-right: 10px;">
- Swagger UI Explorer
- </a>
- <a href="/api-docs" class="download-link">
- ReDoc Documentation
- </a>
- </div>
-
- <h3>📡 WebSocket Events</h3>
- <p>
- When connected via WebSocket, you automatically receive
- real-time event notifications:
- </p>
- <div style="margin-top: 15px; margin-left: 20px;">
- <strong>Player Events:</strong>
- <ul style="margin-left: 20px;">
- <li><code>player_added</code> - New player discovered</li>
- <li><code>player_updated</code> - Player state changed</li>
- <li><code>player_removed</code> - Player disconnected</li>
- <li><code>player_config_updated</code> - Player settings changed</li>
- </ul>
-
- <strong style="margin-top: 10px; display: block;">Queue Events:</strong>
- <ul style="margin-left: 20px;">
- <li><code>queue_added</code> - New queue created</li>
- <li><code>queue_updated</code> - Queue state changed</li>
- <li><code>queue_items_updated</code> - Queue content changed</li>
- <li><code>queue_time_updated</code> - Playback position updated</li>
- </ul>
-
- <strong style="margin-top: 10px; display: block;">Library Events:</strong>
- <ul style="margin-left: 20px;">
- <li><code>media_item_added</code> - New media added to library</li>
- <li><code>media_item_updated</code> - Media metadata updated</li>
- <li><code>media_item_deleted</code> - Media removed from library</li>
- <li><code>media_item_played</code> - Media playback started</li>
- </ul>
-
- <strong style="margin-top: 10px; display: block;">System Events:</strong>
- <ul style="margin-left: 20px;">
- <li><code>providers_updated</code> - Provider status changed</li>
- <li><code>sync_tasks_updated</code> - Sync progress updated</li>
- <li><code>application_shutdown</code> - Server shutting down</li>
- </ul>
- </div>
- </div>
-
- <div class="nav">
- <h3>Quick Navigation</h3>
- <ul>
-""",
- ]
-
- # Add navigation links
- for category in sorted(categories.keys()):
- html_parts.append(
- f' <li><a href="#{category}">{category}</a> '
- f"({len(categories[category])} commands)</li>\n"
- )
-
- html_parts.append(
- """ </ul>
- </div>
-"""
- )
-
- # Add commands by category
- for category, commands in sorted(categories.items()):
- html_parts.append(f' <div class="category" id="{category}">\n')
- html_parts.append(f' <div class="category-header">{category}</div>\n')
-
- for command, handler in commands:
- _, description, param_descriptions = _parse_docstring(handler.target)
-
- html_parts.append(' <div class="command">\n')
- html_parts.append(f' <div class="command-name">{command}</div>\n')
-
- if description:
- html_parts.append(
- f' <div class="command-description">{description}</div>\n'
- )
-
- # Parameters
- params_html = []
- for param_name, param in handler.signature.parameters.items():
- if param_name == "self":
- continue
- # Skip return_type parameter (used only for type hints)
- if param_name == "return_type":
- continue
-
- param_type = handler.type_hints.get(param_name, Any)
- is_required = param.default is inspect.Parameter.empty
- param_desc = param_descriptions.get(param_name, "")
-
- # Format type name
- type_name = _format_type_name(param_type)
- if get_origin(param_type):
- origin = get_origin(param_type)
- args = get_args(param_type)
- if origin is Union or origin is UnionType:
- type_name = " | ".join(_format_type_name(arg) for arg in args)
- elif origin in (list, tuple):
- if args:
- inner_type = _format_type_name(args[0])
- type_name = f"{origin.__name__}[{inner_type}]"
- elif origin is dict:
- if len(args) == 2:
- key_type = _format_type_name(args[0])
- val_type = _format_type_name(args[1])
- type_name = f"dict[{key_type}, {val_type}]"
-
- required_badge = (
- '<span class="param-required">required</span>'
- if is_required
- else '<span class="param-optional">optional</span>'
- )
-
- # Format default value
- default_str = ""
- if not is_required and param.default is not None:
- try:
- if isinstance(param.default, str):
- default_str = f' = "{param.default}"'
- elif isinstance(param.default, Enum):
- default_str = f" = {param.default.value}"
- elif isinstance(param.default, (int, float, bool, list, dict)):
- default_str = f" = {param.default}"
- except Exception: # noqa: S110
- pass # Can't serialize, skip default
-
- params_html.append(
- f' <div class="param">\n'
- f' <span class="param-name">{param_name}</span>\n'
- f' <span class="param-type">'
- f"({type_name}{default_str})</span>\n"
- f" {required_badge}\n"
- )
- if param_desc:
- params_html.append(
- f' <div class="param-description">'
- f"{param_desc}</div>\n"
- )
- params_html.append(" </div>\n")
-
- if params_html:
- html_parts.append(' <div class="params">\n')
- html_parts.append(" <h4>Parameters</h4>\n")
- html_parts.extend(params_html)
- html_parts.append(" </div>\n")
-
- # Return type
- return_type = handler.type_hints.get("return", Any)
- if return_type and return_type is not NoneType:
- type_name = _format_type_name(return_type)
- if get_origin(return_type):
- origin = get_origin(return_type)
- args = get_args(return_type)
- if origin in (list, tuple) and args:
- inner_type = _format_type_name(args[0])
- type_name = f"{origin.__name__}[{inner_type}]"
- elif origin is Union or origin is UnionType:
- type_name = " | ".join(_format_type_name(arg) for arg in args)
-
- html_parts.append(' <div class="returns">\n')
- html_parts.append(" <h4>Returns</h4>\n")
- html_parts.append(
- f' <div class="return-type">{type_name}</div>\n'
- )
- html_parts.append(" </div>\n")
-
- html_parts.append(" </div>\n")
-
- html_parts.append(" </div>\n")
-
- html_parts.append(
- """ </div>
-</body>
-</html>
-"""
- )
-
- return "".join(html_parts)
--- /dev/null
+"""Helpers for validating redirect URLs in OAuth/auth flows."""
+
+from __future__ import annotations
+
+import ipaddress
+import logging
+from typing import TYPE_CHECKING
+from urllib.parse import urlparse
+
+from music_assistant.constants import MASS_LOGGER_NAME
+
+if TYPE_CHECKING:
+ from aiohttp import web
+
+LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.redirect_validation")
+
+# Allowed redirect URI patterns
+# Add custom URL schemes for mobile apps here
+ALLOWED_REDIRECT_PATTERNS = [
+ # Custom URL schemes for mobile apps
+ "musicassistant://", # Music Assistant mobile app
+ # Home Assistant domains
+ "https://my.home-assistant.io/",
+ "http://homeassistant.local/",
+ "https://homeassistant.local/",
+]
+
+
+def is_allowed_redirect_url(
+ url: str,
+ request: web.Request | None = None,
+ base_url: str | None = None,
+) -> tuple[bool, str]:
+ """
+ Validate if a redirect URL is allowed for OAuth/auth flows.
+
+ Security rules (in order of priority):
+ 1. Must use http, https, or registered custom scheme (e.g., musicassistant://)
+ 2. Same origin as the request - auto-allowed (trusted)
+ 3. Localhost (127.0.0.1, ::1, localhost) - auto-allowed (trusted)
+ 4. Private network IPs (RFC 1918) - auto-allowed (trusted)
+ 5. Configured base_url - auto-allowed (trusted)
+ 6. Matches allowed redirect patterns - auto-allowed (trusted)
+ 7. Everything else - requires user consent (external)
+
+ :param url: The redirect URL to validate.
+ :param request: Optional aiohttp request to compare origin.
+ :param base_url: Optional configured base URL to allow.
+ :return: Tuple of (is_valid, category) where category is:
+ - "trusted": Auto-allowed, no consent needed
+ - "external": Valid but requires user consent
+ - "blocked": Invalid/dangerous URL
+ """
+ if not url:
+ return False, "blocked"
+
+ try:
+ parsed = urlparse(url)
+
+ # Check for custom URL schemes (mobile apps)
+ for pattern in ALLOWED_REDIRECT_PATTERNS:
+ if url.startswith(pattern):
+ LOGGER.debug("Redirect URL trusted (pattern match): %s", url)
+ return True, "trusted"
+
+ # Only http/https for web URLs
+ if parsed.scheme not in ("http", "https"):
+ LOGGER.warning("Redirect URL blocked (invalid scheme): %s", url)
+ return False, "blocked"
+
+ hostname = parsed.hostname
+ if not hostname:
+ LOGGER.warning("Redirect URL blocked (no hostname): %s", url)
+ return False, "blocked"
+
+ # 1. Same origin as request - always trusted
+ if request:
+ request_host = request.host
+ if parsed.netloc == request_host:
+ LOGGER.debug("Redirect URL trusted (same origin): %s", url)
+ return True, "trusted"
+
+ # 2. Localhost - always trusted (for development and mobile app testing)
+ if hostname in ("localhost", "127.0.0.1", "::1"):
+ LOGGER.debug("Redirect URL trusted (localhost): %s", url)
+ return True, "trusted"
+
+ # 3. Private network IPs - always trusted (for local network access)
+ if _is_private_ip(hostname):
+ LOGGER.debug("Redirect URL trusted (private IP): %s", url)
+ return True, "trusted"
+
+ # 4. Configured base_url - always trusted
+ if base_url:
+ base_parsed = urlparse(base_url)
+ if parsed.netloc == base_parsed.netloc:
+ LOGGER.debug("Redirect URL trusted (base_url): %s", url)
+ return True, "trusted"
+
+ # If we get here, URL is external and requires user consent
+ LOGGER.info("Redirect URL is external (requires consent): %s", url)
+ return True, "external"
+
+ except Exception as e:
+ LOGGER.exception("Error validating redirect URL: %s", e)
+ return False, "blocked"
+
+
+def _is_private_ip(hostname: str) -> bool:
+ """Check if hostname is a private IP address (RFC 1918)."""
+ try:
+ ip = ipaddress.ip_address(hostname)
+ return ip.is_private
+ except ValueError:
+ # Not a valid IP address
+ return False
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Music Assistant API Documentation</title>
+ <link rel="stylesheet" href="../resources/common.css">
<style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, sans-serif;
- line-height: 1.7;
- color: #2c3e50;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
- padding: 20px;
+ padding: 40px 20px;
}
+
.container {
- max-width: 1000px;
+ max-width: 1100px;
margin: 0 auto;
- background: white;
- border-radius: 16px;
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
+
.header {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 40px 40px;
text-align: center;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 15px;
- }
- .header .logo {
- width: 60px;
- height: 60px;
+ padding: 48px 40px;
+ border-bottom: 1px solid var(--border);
}
+
.header h1 {
- font-size: 2.2em;
- margin: 0;
+ color: var(--fg);
+ font-size: 32px;
font-weight: 600;
+ letter-spacing: -0.5px;
+ margin-bottom: 8px;
}
+
.header .version {
- font-size: 1em;
- opacity: 0.9;
- font-weight: 300;
+ color: var(--text-tertiary);
+ font-size: 14px;
}
+
.content {
- padding: 35px 40px;
+ padding: 40px;
}
+
.section {
- margin-bottom: 35px;
+ margin-bottom: 48px;
}
+
+ .section:last-child {
+ margin-bottom: 0;
+ }
+
.section h2 {
- color: #667eea;
- font-size: 1.6em;
- margin-bottom: 15px;
- padding-bottom: 8px;
- border-bottom: 2px solid #667eea;
+ color: var(--primary);
+ font-size: 24px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 2px solid var(--primary);
}
+
.section h3 {
- color: #764ba2;
- font-size: 1.3em;
- margin: 25px 0 12px 0;
+ color: var(--fg);
+ font-size: 18px;
+ font-weight: 600;
+ margin: 32px 0 16px 0;
}
+
.section p {
- margin-bottom: 12px;
- font-size: 1em;
- line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+ font-size: 15px;
}
+
.api-boxes {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
- margin: 20px 0;
+ margin: 24px 0;
}
+
.api-box {
- background: #f8f9fa;
- padding: 25px;
+ background: var(--input-bg);
+ padding: 28px;
border-radius: 12px;
- border-left: 5px solid #667eea;
- transition: transform 0.2s, box-shadow 0.2s;
+ border: 1px solid var(--border);
+ transition: all 0.2s ease;
}
+
.api-box:hover {
- transform: translateY(-5px);
- box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px var(--primary-glow);
+ border-color: var(--primary);
}
+
.api-box h4 {
- color: #667eea;
- font-size: 1.2em;
- margin-bottom: 10px;
+ color: var(--fg);
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 12px;
}
+
.api-box p {
- color: #666;
+ color: var(--text-secondary);
+ font-size: 14px;
margin-bottom: 20px;
- font-size: 0.95em;
}
+
.api-box .btn {
display: inline-block;
- background: #667eea;
+ background: var(--primary);
color: white;
padding: 12px 24px;
- border-radius: 6px;
+ border-radius: 10px;
text-decoration: none;
- font-weight: 500;
- transition: background 0.3s;
+ font-weight: 600;
+ font-size: 14px;
+ transition: all 0.2s ease;
}
+
.api-box .btn:hover {
- background: #764ba2;
+ filter: brightness(1.1);
+ box-shadow: 0 4px 12px var(--primary-glow);
+ transform: translateY(-1px);
}
+
.code-block {
- background: #2d2d2d;
- color: #f8f8f2;
- padding: 25px;
- border-radius: 8px;
- overflow-x: auto;
- margin: 20px 0;
- font-family: 'Monaco', 'Courier New', monospace;
- font-size: 0.9em;
- line-height: 1.6;
white-space: pre-wrap;
}
+
.code-block .comment {
- color: #75715e;
+ color: var(--code-comment);
}
+
.code-block .string {
- color: #e6db74;
+ color: var(--code-string);
}
+
.code-block .keyword {
- color: #66d9ef;
- }
- .code-block .number {
- color: #ae81ff;
+ color: var(--code-keyword);
}
+
.highlight {
- background: #fff3cd;
+ background: var(--input-focus-bg);
padding: 2px 6px;
- border-radius: 3px;
+ border-radius: 4px;
font-weight: 500;
+ color: var(--primary);
}
+
+ code {
+ background: var(--input-bg);
+ color: var(--primary);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.9em;
+ font-family: 'Monaco', 'Courier New', monospace;
+ }
+
.info-box {
- background: #e7f3ff;
- border-left: 4px solid #2196F3;
+ background: var(--info-bg);
+ border: 1px solid var(--info-border);
+ border-left: 4px solid var(--primary);
padding: 20px;
margin: 20px 0;
- border-radius: 6px;
+ border-radius: 10px;
}
+
.info-box strong {
- color: #2196F3;
+ color: var(--primary);
}
+
.events-list {
- background: #f8f9fa;
- padding: 25px;
- border-radius: 8px;
+ background: var(--input-bg);
+ padding: 24px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
margin: 20px 0;
}
+
.events-list h4 {
- color: #667eea;
- margin-bottom: 15px;
- font-size: 1.2em;
+ color: var(--fg);
+ font-weight: 600;
+ margin-bottom: 16px;
+ font-size: 16px;
}
+
.events-list ul {
list-style: none;
padding-left: 0;
}
+
.events-list li {
- padding: 8px 0;
- border-bottom: 1px solid #e0e0e0;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-secondary);
}
+
.events-list li:last-child {
border-bottom: none;
}
+
.events-list code {
- background: #667eea;
+ background: var(--primary);
color: white;
- padding: 3px 8px;
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ }
+
+ .client-links {
+ display: flex;
+ gap: 15px;
+ margin: 20px 0;
+ flex-wrap: wrap;
+ }
+
+ .client-link {
+ background: var(--input-bg);
+ color: var(--fg);
+ padding: 12px 24px;
+ border-radius: 10px;
+ text-decoration: none;
+ font-weight: 600;
+ font-size: 14px;
+ border: 1px solid var(--border);
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .client-link:hover {
+ background: var(--input-focus-bg);
+ border-color: var(--primary);
+ transform: translateY(-1px);
+ }
+
+ .footer {
+ background: var(--input-bg);
+ padding: 24px 40px;
+ text-align: center;
+ color: var(--text-secondary);
+ border-top: 1px solid var(--border);
+ font-size: 14px;
+ }
+
+ .footer a {
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: 500;
+ }
+
+ .footer a:hover {
+ text-decoration: underline;
+ }
+
+ ul {
+ margin-left: 24px;
+ margin-bottom: 16px;
+ }
+
+ ul li {
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ }
+
+ .code-block .keyword {
+ color: var(--code-keyword);
+ }
+
+ .highlight {
+ background: var(--input-focus-bg);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-weight: 500;
+ color: var(--primary);
+ }
+
+ code {
+ background: var(--input-bg);
+ color: var(--primary);
+ padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
+ font-family: 'Monaco', 'Courier New', monospace;
+ }
+
+ .info-box {
+ background: var(--info-bg);
+ border: 1px solid var(--info-border);
+ border-left: 4px solid var(--primary);
+ padding: 20px;
+ margin: 20px 0;
+ border-radius: 10px;
+ }
+
+ .info-box strong {
+ color: var(--primary);
+ }
+
+ .events-list {
+ background: var(--input-bg);
+ padding: 24px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ margin: 20px 0;
+ }
+
+ .events-list h4 {
+ color: var(--fg);
+ font-weight: 600;
+ margin-bottom: 16px;
+ font-size: 16px;
+ }
+
+ .events-list ul {
+ list-style: none;
+ padding-left: 0;
+ }
+
+ .events-list li {
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-secondary);
+ }
+
+ .events-list li:last-child {
+ border-bottom: none;
+ }
+
+ .events-list code {
+ background: var(--primary);
+ color: white;
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 13px;
font-weight: 500;
}
+
.client-links {
display: flex;
gap: 15px;
margin: 20px 0;
flex-wrap: wrap;
}
+
.client-link {
- background: #764ba2;
- color: white;
+ background: var(--input-bg);
+ color: var(--fg);
padding: 12px 24px;
- border-radius: 6px;
+ border-radius: 10px;
text-decoration: none;
- font-weight: 500;
- transition: background 0.3s;
+ font-weight: 600;
+ font-size: 14px;
+ border: 1px solid var(--border);
+ transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
+
.client-link:hover {
- background: #667eea;
+ background: var(--input-focus-bg);
+ border-color: var(--primary);
+ transform: translateY(-1px);
}
+
.footer {
- background: #f8f9fa;
- padding: 30px 40px;
+ background: var(--input-bg);
+ padding: 24px 40px;
text-align: center;
- color: #666;
- border-top: 1px solid #e0e0e0;
+ color: var(--text-secondary);
+ border-top: 1px solid var(--border);
+ font-size: 14px;
+ }
+
+ .footer a {
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: 500;
+ }
+
+ .footer a:hover {
+ text-decoration: underline;
+ }
+
+ ul {
+ margin-left: 24px;
+ margin-bottom: 16px;
+ }
+
+ ul li {
+ color: var(--text-secondary);
+ margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
- <svg class="logo" viewBox="0 0 240 240" fill="none">
- <g clip-path="url(#clipPath19)" transform="translate(0,2)">
- <path d="m 109.394,4.3814 c 5.848,-5.84187 15.394,-5.84187 21.212,0 l 98.788,98.8876 c 5.848,5.842 10.606,17.374 10.606,25.638 v 90.11 l -0.005,0.356 c -0.206,8.086 -6.881,14.628 -14.995,14.628 H 15 C 6.75759,234.001 2.40473e-5,227.22 0,218.987 v -90.11 c 1.20331e-4,-8.264 4.78834,-19.796 10.6064,-25.638 z" fill="#f2f4f9"/>
- <path d="m 109.394,4.3814 c 5.848,-5.84187 15.394,-5.84187 21.212,0 l 98.788,98.8876 c 5.848,5.842 10.606,17.374 10.606,25.638 v 90.11 l -0.005,0.356 c -0.206,8.086 -6.881,14.628 -14.995,14.628 H 15 C 6.75759,234.001 2.40473e-5,227.22 0,218.987 v -90.11 c 1.20331e-4,-8.264 4.78834,-19.796 10.6064,-25.638 z M 36,120.001 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.5817,-8 -8,-8 z m 32,0 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.5817,-8 -8,-8 z m 32,0 c -4.4183,0 -8,3.581 -8,8 v 78 h 16 v -78 c 0,-4.419 -3.582,-8 -8,-8 z m 58.393,0.426 c -4.193,-1.395 -8.722,0.873 -10.118,5.065 l -26.796,80.509 h 16.863 l 25.114,-75.457 c 1.395,-4.192 -0.872,-8.721 -5.063,-10.117 z m 30.315,5.065 c -1.395,-4.192 -5.925,-6.46 -10.117,-5.065 -4.192,1.396 -6.46,5.925 -5.065,10.117 l 25.116,75.457 h 16.862 z" fill="#18bcf2"/>
- </g>
- <defs>
- <clipPath id="clipPath19" clipPathUnits="userSpaceOnUse">
- <rect width="239.88728" height="239.55457" x="0.14213564" y="0.010407645"/>
- </clipPath>
- </defs>
- </svg>
+ <div class="logo">
+ <img src="/logo.png" alt="Music Assistant">
+ </div>
<h1>Music Assistant API</h1>
<div class="version">Version {VERSION}</div>
</div>
<span class="comment"># Connect to WebSocket</span>
ws://{SERVER_HOST}/ws
-<span class="comment"># Send a command (message_id is REQUIRED)</span>
+<span class="comment"># Step 1: Authenticate (REQUIRED as first command)</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"auth-123"</span>,
+ <span class="string">"command"</span>: <span class="string">"auth"</span>,
+ <span class="string">"args"</span>: {
+ <span class="string">"token"</span>: <span class="string">"your_access_token"</span>
+ }
+}
+
+<span class="comment"># Auth response</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"auth-123"</span>,
+ <span class="string">"result"</span>: {
+ <span class="string">"authenticated"</span>: <span class="keyword">true</span>,
+ <span class="string">"user"</span>: {<span class="comment">...user info...</span>}
+ }
+}
+
+<span class="comment"># Step 2: Send commands (message_id is REQUIRED)</span>
{
<span class="string">"message_id"</span>: <span class="string">"unique-id-123"</span>,
<span class="string">"command"</span>: <span class="string">"players/all"</span>,
since each HTTP request is isolated. The response returns the command result directly.
</div>
<div class="code-block">
-<span class="comment"># Get all players</span>
+<span class="comment"># Get all players (requires authentication)</span>
curl -X POST {BASE_URL}/api \
+ -H <span class="string">"Authorization: Bearer your_access_token"</span> \
-H <span class="string">"Content-Type: application/json"</span> \
-d <span class="string">'{
"command": "players/all",
"args": {}
}'</span>
-<span class="comment"># Play media on a player</span>
+<span class="comment"># Play media on a player (requires authentication)</span>
curl -X POST {BASE_URL}/api \
+ -H <span class="string">"Authorization: Bearer your_access_token"</span> \
-H <span class="string">"Content-Type: application/json"</span> \
-d <span class="string">'{
"command": "player_queues/play_media",
}
}'</span>
-<span class="comment"># Get server info</span>
+<span class="comment"># Get server info (no authentication required)</span>
curl {BASE_URL}/info
</div>
</div>
<div class="section">
<h2>Best Practices</h2>
<p><strong>✓ Do:</strong></p>
- <ul style="margin-left: 30px; margin-bottom: 15px;">
+ <ul>
<li>Use WebSocket API for real-time applications</li>
<li>Handle connection drops and reconnect automatically</li>
<li>Subscribe to relevant events instead of polling</li>
<li>Implement proper error handling</li>
</ul>
<p><strong>✗ Don't:</strong></p>
- <ul style="margin-left: 30px;">
+ <ul>
<li>Poll the REST API frequently for updates (use WebSocket events instead)</li>
<li>Send commands without waiting for previous responses</li>
<li>Ignore error responses</li>
<div class="section">
<h2>Authentication</h2>
+ <p>
+ As of API Schema Version 28, <span class="highlight">authentication is now mandatory</span> for all API access
+ (except when accessed through Home Assistant Ingress).
+ </p>
+
+ <h3>Authentication Overview</h3>
+ <p>Music Assistant supports the following authentication methods:</p>
+ <ul>
+ <li><strong>Username/Password</strong> - Built-in authentication provider</li>
+ <li><strong>Home Assistant OAuth</strong> - OAuth flow for HA users (optional)</li>
+ <li><strong>Bearer Tokens</strong> - Token-based authentication for HTTP and WebSocket</li>
+ </ul>
+
+ <h3>HTTP Authentication Endpoints</h3>
+ <p>The following HTTP endpoints are available for authentication (no auth required):</p>
+ <div class="code-block">
+<span class="comment"># Get server info (includes onboard_done status)</span>
+GET {BASE_URL}/info
+
+<span class="comment"># Get available login providers</span>
+GET {BASE_URL}/auth/providers
+
+<span class="comment"># Login with credentials (built-in provider is default)</span>
+POST {BASE_URL}/auth/login
+{
+ <span class="string">"credentials"</span>: {
+ <span class="string">"username"</span>: <span class="string">"your_username"</span>,
+ <span class="string">"password"</span>: <span class="string">"your_password"</span>
+ }
+}
+
+<span class="comment"># Or specify a different provider (e.g., Home Assistant OAuth)</span>
+POST {BASE_URL}/auth/login
+{
+ <span class="string">"provider_id"</span>: <span class="string">"homeassistant"</span>,
+ <span class="string">"credentials"</span>: {<span class="comment">...provider-specific...</span>}
+}
+
+<span class="comment"># Response includes access token</span>
+{
+ <span class="string">"success"</span>: <span class="keyword">true</span>,
+ <span class="string">"token"</span>: <span class="string">"your_access_token"</span>,
+ <span class="string">"user"</span>: { <span class="comment">...user info...</span> }
+}
+
+<span class="comment"># First-time setup (only if no users exist)</span>
+POST {BASE_URL}/setup
+{
+ <span class="string">"username"</span>: <span class="string">"admin"</span>,
+ <span class="string">"password"</span>: <span class="string">"secure_password"</span>
+}
+ </div>
+
+ <h3>Using Bearer Tokens</h3>
+ <p>Once you have an access token, include it in all HTTP requests:</p>
+ <div class="code-block">
+curl -X POST {BASE_URL}/api \
+ -H <span class="string">"Authorization: Bearer your_access_token"</span> \
+ -H <span class="string">"Content-Type: application/json"</span> \
+ -d <span class="string">'{
+ "command": "players/all",
+ "args": {}
+ }'</span>
+ </div>
+
+ <h3>WebSocket Authentication</h3>
+ <p>
+ After establishing a WebSocket connection, you <strong>must</strong> send an
+ <code>auth</code> command as the first message:
+ </p>
+ <div class="code-block">
+<span class="comment"># Send auth command immediately after connection</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"auth-123"</span>,
+ <span class="string">"command"</span>: <span class="string">"auth"</span>,
+ <span class="string">"args"</span>: {
+ <span class="string">"token"</span>: <span class="string">"your_access_token"</span>
+ }
+}
+
+<span class="comment"># Response on success</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"auth-123"</span>,
+ <span class="string">"result"</span>: {
+ <span class="string">"authenticated"</span>: <span class="keyword">true</span>,
+ <span class="string">"user"</span>: {
+ <span class="string">"user_id"</span>: <span class="string">"..."</span>,
+ <span class="string">"username"</span>: <span class="string">"your_username"</span>,
+ <span class="string">"role"</span>: <span class="string">"admin"</span>
+ }
+ }
+}
+ </div>
+
<div class="info-box">
- <strong>Note:</strong> Authentication is not yet implemented but will be added
- in a future release. For now, ensure your Music Assistant server is not directly
- exposed to the internet. Use a VPN or reverse proxy for secure access.
+ <strong>Token Types:</strong>
+ <ul style="margin-top: 12px;">
+ <li><strong>Short-lived tokens:</strong> Created automatically during login. Expire after 30 days of inactivity but auto-renew on each use (sliding expiration window). Perfect for user sessions.</li>
+ <li><strong>Long-lived tokens:</strong> Created via <code>auth/token/create</code> command. Expire after 10 years with no auto-renewal. Intended for external integrations (Home Assistant, mobile apps, API access).</li>
+ </ul>
+ Use the <code>auth/tokens</code> and <code>auth/token/create</code> WebSocket commands to manage your tokens.
</div>
+
+ <h3>User Management Commands</h3>
+ <p>The following WebSocket commands are available for authentication management:</p>
+ <ul>
+ <li><code>auth/users</code> - List all users (admin only)</li>
+ <li><code>auth/user</code> - Get user by ID (admin only)</li>
+ <li><code>auth/user/create</code> - Create a new user (admin only)</li>
+ <li><code>auth/user/update</code> - Update user profile, password, or role (admin for other users)</li>
+ <li><code>auth/user/enable</code> - Enable user (admin only)</li>
+ <li><code>auth/user/disable</code> - Disable user (admin only)</li>
+ <li><code>auth/user/delete</code> - Delete user (admin only)</li>
+ <li><code>auth/tokens</code> - List your tokens</li>
+ <li><code>auth/token/create</code> - Create a new long-lived token</li>
+ <li><code>auth/token/revoke</code> - Revoke a token</li>
+ </ul>
+ <p>See the <a href="{BASE_URL}/api-docs/commands#auth" style="color: var(--primary); text-decoration: none; font-weight: 500;">Commands Reference</a> for detailed documentation of all auth commands.</p>
</div>
</div>
<div class="footer">
<p>
Music Assistant {VERSION} •
- <a href="https://music-assistant.io" style="color: #667eea;">music-assistant.io</a> •
- <a href="https://github.com/music-assistant" style="color: #667eea;">GitHub</a>
+ <a href="https://music-assistant.io">music-assistant.io</a> •
+ <a href="https://github.com/music-assistant">GitHub</a>
</p>
</div>
</div>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Music Assistant API - Commands Reference</title>
+ <link rel="stylesheet" href="../resources/common.css">
+ <style>
+ .nav-container {
+ background: var(--panel);
+ padding: 1rem 2rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+ .search-box input {
+ width: 100%;
+ max-width: 600px;
+ padding: 0.6rem 1rem;
+ font-size: 0.95em;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ display: block;
+ margin: 0 auto;
+ }
+ .search-box input:focus {
+ outline: none;
+ border-color: var(--primary);
+ }
+ .quick-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--border);
+ }
+ .quick-nav a {
+ padding: 0.4rem 1rem;
+ background: var(--panel);
+ color: var(--primary);
+ text-decoration: none;
+ border-radius: 6px;
+ font-size: 0.9em;
+ transition: all 0.2s;
+ }
+ .quick-nav a:hover {
+ background: var(--primary);
+ color: var(--fg);
+ }
+ .container {
+ max-width: 1200px;
+ margin: 2rem auto;
+ padding: 0 2rem;
+ }
+ .category {
+ background: var(--panel);
+ margin-bottom: 2rem;
+ border-radius: 12px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.08);
+ overflow: hidden;
+ }
+ .category-header {
+ background: var(--primary);
+ color: var(--fg);
+ padding: 1rem 1.5rem;
+ font-size: 1.2em;
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+ }
+ .category-header:hover {
+ background: var(--primary);
+ }
+ .command {
+ border-bottom: 1px solid var(--border);
+ }
+ .command:last-child {
+ border-bottom: none;
+ }
+ .command-header {
+ padding: 1rem 1.5rem;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: background 0.2s;
+ background: var(--input-bg);
+ }
+ .command-header:hover {
+ background: var(--input-focus-bg);
+ }
+ .command-title {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ flex: 1;
+ }
+ .command-name {
+ font-size: 1.1em;
+ font-weight: 600;
+ color: var(--primary);
+ font-family: 'Monaco', 'Courier New', monospace;
+ }
+ .command-summary {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+ }
+ .command-expand-icon {
+ color: var(--primary);
+ font-size: 1.2em;
+ transition: transform 0.3s;
+ }
+ .command-expand-icon.expanded {
+ transform: rotate(180deg);
+ }
+ .command-details {
+ padding: 0 1.5rem 1.5rem 1.5rem;
+ display: none;
+ }
+ .command-details.show {
+ display: block;
+ }
+ .command-description {
+ color: var(--text-secondary);
+ margin-bottom: 1rem;
+ }
+ .return-type {
+ background: #e8f5e9;
+ padding: 0.5rem 1rem;
+ margin: 1rem 0;
+ border-radius: 6px;
+ border-left: 3px solid #4caf50;
+ }
+ .return-type-label {
+ font-weight: 600;
+ color: #2e7d32;
+ margin-right: 0.5rem;
+ }
+ .return-type-value {
+ font-family: 'Monaco', 'Courier New', monospace;
+ color: #2e7d32;
+ }
+ .params-section {
+ margin: 1rem 0;
+ }
+ .params-title {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.5rem;
+ }
+ .param {
+ background: var(--panel);
+ padding: 0.5rem 1rem;
+ margin: 0.5rem 0;
+ border-radius: 6px;
+ border-left: 3px solid var(--primary);
+ }
+ .param-name {
+ font-family: 'Monaco', 'Courier New', monospace;
+ color: var(--primary);
+ font-weight: 600;
+ }
+ .param-required {
+ color: #e74c3c;
+ font-size: 0.8em;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ }
+ .param-type {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ margin-left: 0.5rem;
+ }
+ .param-description {
+ color: var(--text-secondary);
+ margin-top: 0.25rem;
+ }
+ .example {
+ background: #2d2d2d;
+ color: #f8f8f2;
+ padding: 1rem;
+ border-radius: 8px;
+ margin: 1rem 0;
+ overflow-x: auto;
+ position: relative;
+ }
+ .example-title {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.5rem;
+ }
+ .example pre {
+ margin: 0;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.9em;
+ }
+ .copy-btn {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ background: var(--primary);
+ color: var(--fg);
+ border: none;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.8em;
+ }
+ .copy-btn:hover {
+ background: var(--primary);
+ }
+ .hidden {
+ display: none;
+ }
+ .tabs {
+ margin: 1rem 0;
+ }
+ .tab-buttons {
+ display: flex;
+ gap: 0.5rem;
+ border-bottom: 2px solid #ddd;
+ margin-bottom: 1rem;
+ }
+ .tab-btn {
+ background: none;
+ border: none;
+ padding: 0.8rem 1.5rem;
+ font-size: 1em;
+ cursor: pointer;
+ color: var(--text-secondary);
+ border-bottom: 3px solid transparent;
+ transition: all 0.3s;
+ }
+ .tab-btn:hover {
+ color: var(--primary);
+ }
+ .tab-btn.active {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+ }
+ .tab-content {
+ display: none;
+ }
+ .tab-content.active {
+ display: block;
+ }
+ .try-it-section {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+ .json-input {
+ width: 100%;
+ min-height: 150px;
+ padding: 1rem;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.9em;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ background: #2d2d2d;
+ color: #f8f8f2;
+ resize: vertical;
+ }
+ .json-input:focus {
+ outline: none;
+ border-color: var(--primary);
+ }
+ .try-btn {
+ align-self: flex-start;
+ background: var(--primary);
+ color: var(--fg);
+ border: none;
+ padding: 0.8rem 2rem;
+ border-radius: 8px;
+ font-size: 1em;
+ cursor: pointer;
+ transition: background 0.3s;
+ }
+ .try-btn:hover {
+ background: var(--primary);
+ }
+ .try-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ }
+ .response-output {
+ background: #2d2d2d;
+ color: #f8f8f2;
+ padding: 1rem;
+ border-radius: 8px;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.9em;
+ min-height: 100px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ display: none;
+ }
+ .response-output.show {
+ display: block;
+ }
+ .response-output.error {
+ background: #ffebee;
+ color: #c62828;
+ }
+ .response-output.success {
+ background: #e8f5e9;
+ color: #2e7d32;
+ }
+ .type-link {
+ color: var(--primary);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--primary);
+ transition: all 0.2s;
+ }
+ .type-link:hover {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+ }
+ .type-union {
+ margin-top: 0.5rem;
+ }
+ .type-union-label {
+ font-weight: 600;
+ color: #4a5568;
+ display: block;
+ margin-bottom: 0.25rem;
+ }
+ .type-union ul {
+ margin: 0.25rem 0 0 0;
+ padding-left: 1.5rem;
+ list-style-type: disc;
+ }
+ .type-union li {
+ margin: 0.25rem 0;
+ color: #2d3748;
+ }
+ .param-type-union {
+ display: block;
+ margin-top: 0.25rem;
+ }
+ .auth-section {
+ background: var(--panel);
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+ border: 2px solid var(--border);
+ }
+ .auth-section.authenticated {
+ border-color: var(--success-border);
+ background: var(--success-bg);
+ }
+ .auth-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.8rem;
+ font-weight: 600;
+ color: var(--fg);
+ }
+ .auth-status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #f44336;
+ }
+ .auth-status-dot.authenticated {
+ background: var(--success);
+ }
+ .auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+ }
+ .auth-form input {
+ padding: 0.6rem;
+ border: 2px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.95em;
+ background: var(--panel);
+ color: var(--fg);
+ }
+ .auth-form input:focus {
+ outline: none;
+ border-color: var(--primary);
+ }
+ .auth-form button {
+ padding: 0.6rem 1.2rem;
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.95em;
+ cursor: pointer;
+ transition: background 0.3s;
+ }
+ .auth-form button:hover {
+ filter: brightness(1.1);
+ }
+ .auth-form button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ }
+ .auth-user-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .auth-user-details {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+ }
+ .auth-logout-btn {
+ padding: 0.5rem 1rem;
+ background: #f44336;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9em;
+ cursor: pointer;
+ transition: background 0.3s;
+ }
+ .auth-logout-btn:hover {
+ filter: brightness(0.9);
+ }
+ .auth-error {
+ background: #ffebee;
+ color: #c62828;
+ padding: 0.6rem;
+ border-radius: 6px;
+ font-size: 0.9em;
+ margin-top: 0.5rem;
+ }
+ .role-badge {
+ display: inline-block;
+ padding: 0.2rem 0.6rem;
+ border-radius: 4px;
+ font-size: 0.75em;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ text-transform: uppercase;
+ }
+ .role-badge.admin {
+ background: #ffebee;
+ color: #c62828;
+ }
+ .role-badge.user {
+ background: #e3f2fd;
+ color: #1976d2;
+ }
+ .header .logo {
+ margin-bottom: 1rem;
+ }
+ .header .logo img {
+ width: 60px;
+ height: 60px;
+ object-fit: contain;
+ }
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ font-size: 1.2em;
+ color: var(--text-secondary);
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <div class="logo">
+ <img src="../logo.png" alt="Music Assistant">
+ </div>
+ <h1>Commands Reference</h1>
+ <p>Complete list of Music Assistant API commands</p>
+ </div>
+
+ <div class="nav-container">
+ <div class="auth-section" id="authSection">
+ <div class="auth-status">
+ <span class="auth-status-dot" id="authDot"></span>
+ <span id="authStatusText">Not Authenticated</span>
+ </div>
+ <div id="authFormContainer">
+ <form class="auth-form" id="loginForm" onsubmit="return handleLogin(event)">
+ <input type="text" id="username" placeholder="Username" required />
+ <input type="password" id="password" placeholder="Password" required />
+ <button type="submit" id="loginBtn">Login</button>
+ </form>
+ <div id="authError" class="auth-error" style="display: none;"></div>
+ </div>
+ <div id="authUserInfo" class="auth-user-info" style="display: none;">
+ <div class="auth-user-details">
+ <div>Logged in as: <strong id="authUsername"></strong></div>
+ <div>Role: <strong id="authRole"></strong></div>
+ </div>
+ <button class="auth-logout-btn" onclick="handleLogout()">Logout</button>
+ </div>
+ </div>
+ <div class="search-box">
+ <input type="text" id="search" placeholder="Search commands..." />
+ </div>
+ <div class="quick-nav" id="quickNav">
+ <!-- Navigation links will be generated dynamically -->
+ </div>
+ </div>
+
+ <div class="container" id="container">
+ <div class="loading">Loading commands...</div>
+ </div>
+
+ <script>
+ // Get server URL from current location
+ const SERVER_URL = window.location.origin;
+
+ // Authentication functionality
+ const TOKEN_STORAGE_KEY = 'ma_api_token';
+ const USER_STORAGE_KEY = 'ma_api_user';
+
+ // Check for existing token on page load
+ async function checkAuth() {
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+ const userStr = localStorage.getItem(USER_STORAGE_KEY);
+
+ if (token && userStr) {
+ try {
+ const user = JSON.parse(userStr);
+
+ // Validate token by making a JSON-RPC call that requires auth
+ const response = await fetch('/api', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ command: 'info'
+ })
+ });
+
+ if (response.ok) {
+ // Token is valid
+ updateAuthUI(true, user);
+ } else {
+ // Token is invalid (revoked, expired, etc.)
+ clearAuth();
+ }
+ } catch (e) {
+ // Network error or invalid JSON
+ clearAuth();
+ }
+ }
+ }
+
+ // Handle login form submission
+ async function handleLogin(event) {
+ event.preventDefault();
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+ const loginBtn = document.getElementById('loginBtn');
+ const errorDiv = document.getElementById('authError');
+
+ // Disable button and show loading
+ loginBtn.disabled = true;
+ loginBtn.textContent = 'Logging in...';
+ errorDiv.style.display = 'none';
+
+ try {
+ const response = await fetch('/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ credentials: {
+ username: username,
+ password: password
+ }
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success && result.token && result.user) {
+ // Store token and user info
+ localStorage.setItem(TOKEN_STORAGE_KEY, result.token);
+ localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(result.user));
+
+ // Update UI
+ updateAuthUI(true, result.user);
+
+ // Clear form
+ document.getElementById('loginForm').reset();
+ } else {
+ // Show error
+ errorDiv.textContent = result.error || 'Login failed';
+ errorDiv.style.display = 'block';
+ }
+ } catch (error) {
+ errorDiv.textContent = 'Connection error: ' + error.message;
+ errorDiv.style.display = 'block';
+ } finally {
+ loginBtn.disabled = false;
+ loginBtn.textContent = 'Login';
+ }
+
+ return false;
+ }
+
+ // Handle logout
+ function handleLogout() {
+ clearAuth();
+ }
+
+ // Clear authentication
+ function clearAuth() {
+ localStorage.removeItem(TOKEN_STORAGE_KEY);
+ localStorage.removeItem(USER_STORAGE_KEY);
+ updateAuthUI(false, null);
+ }
+
+ // Update auth UI
+ function updateAuthUI(authenticated, user) {
+ const authSection = document.getElementById('authSection');
+ const authDot = document.getElementById('authDot');
+ const authStatusText = document.getElementById('authStatusText');
+ const authFormContainer = document.getElementById('authFormContainer');
+ const authUserInfo = document.getElementById('authUserInfo');
+
+ if (authenticated && user) {
+ authSection.classList.add('authenticated');
+ authDot.classList.add('authenticated');
+ authStatusText.textContent = 'Authenticated';
+ authFormContainer.style.display = 'none';
+ authUserInfo.style.display = 'flex';
+
+ document.getElementById('authUsername').textContent = user.username;
+ document.getElementById('authRole').textContent = user.role;
+
+ // Update cURL examples with actual token
+ updateCurlExamples();
+ } else {
+ authSection.classList.remove('authenticated');
+ authDot.classList.remove('authenticated');
+ authStatusText.textContent = 'Not Authenticated';
+ authFormContainer.style.display = 'block';
+ authUserInfo.style.display = 'none';
+
+ // Reset cURL examples to placeholder
+ updateCurlExamples();
+ }
+ }
+
+ // Update all cURL examples with actual token or placeholder
+ function updateCurlExamples() {
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+ const curlBlocks = document.querySelectorAll('.example pre');
+
+ curlBlocks.forEach(block => {
+ const curlText = block.textContent;
+
+ // Only update if it contains an Authorization header
+ if (curlText.includes('Authorization: Bearer')) {
+ if (token) {
+ // Replace placeholder with actual token
+ block.textContent = curlText.replace(
+ /Authorization: Bearer YOUR_ACCESS_TOKEN/g,
+ `Authorization: Bearer ${token}`
+ );
+ } else {
+ // Replace actual token with placeholder
+ block.textContent = curlText.replace(
+ /Authorization: Bearer \S+/g,
+ 'Authorization: Bearer YOUR_ACCESS_TOKEN'
+ );
+ }
+ }
+ });
+ }
+
+ // Initialize auth on page load
+ checkAuth();
+
+ // Helper function to make type links
+ function makeTypeLinks(typeStr, asList = false) {
+ // Find all complex types (capitalized words that aren't basic types)
+ const excluded = ['Union', 'Optional', 'List', 'Dict', 'Array', 'None', 'NoneType'];
+
+ function replaceType(typeStr) {
+ return typeStr.replace(/\b[A-Z][a-zA-Z0-9_]*\b/g, (match) => {
+ if (match[0] === match[0].toUpperCase() && !excluded.includes(match)) {
+ const schemaUrl = `${SERVER_URL}/api-docs/schemas#schema-${match}`;
+ return `<a href="${schemaUrl}" class="type-link">${match}</a>`;
+ }
+ return match;
+ });
+ }
+
+ // If it's a union type with multiple options and asList is true, format as bullet list
+ if (asList && typeStr.includes(' | ')) {
+ const parts = typeStr.split(' | ');
+ // Only use list format if there are 3+ options
+ if (parts.length >= 3) {
+ let html = '<div class="type-union"><span class="type-union-label">Any of:</span><ul>';
+ for (const part of parts) {
+ const linkedPart = replaceType(part);
+ html += `<li>${linkedPart}</li>`;
+ }
+ html += '</ul></div>';
+ return html;
+ }
+ }
+
+ // Replace complex type names with links
+ return replaceType(typeStr);
+ }
+
+ // Helper function to escape HTML
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // Helper function to build example args
+ function buildExampleArgs(parameters) {
+ const exampleArgs = {};
+
+ for (const param of parameters) {
+ // Include optional params if few params
+ if (param.required || parameters.length <= 2) {
+ const typeStr = param.type;
+
+ if (typeStr === 'string') {
+ exampleArgs[param.name] = 'example_value';
+ } else if (typeStr === 'integer') {
+ exampleArgs[param.name] = 0;
+ } else if (typeStr === 'number') {
+ exampleArgs[param.name] = 0.0;
+ } else if (typeStr === 'boolean') {
+ exampleArgs[param.name] = true;
+ } else if (typeStr === 'object') {
+ exampleArgs[param.name] = {};
+ } else if (typeStr === 'null') {
+ exampleArgs[param.name] = null;
+ } else if (typeStr === 'ConfigValueType') {
+ exampleArgs[param.name] = 'example_value';
+ } else if (typeStr === 'MediaItemType') {
+ exampleArgs[param.name] = { "_comment": "See MediaItemType schema for details" };
+ } else if (typeStr.startsWith('Array of ')) {
+ const itemType = typeStr.substring(9);
+ if (['string', 'integer', 'number', 'boolean'].includes(itemType)) {
+ exampleArgs[param.name] = [];
+ } else {
+ exampleArgs[param.name] = [{ "_comment": `See ${itemType} schema in Swagger UI` }];
+ }
+ } else {
+ // Complex type - use placeholder object
+ const primaryType = typeStr.includes(' | ') ? typeStr.split(' | ')[0] : typeStr;
+ exampleArgs[param.name] = { "_comment": `See ${primaryType} schema in Swagger UI` };
+ }
+ }
+ }
+
+ return exampleArgs;
+ }
+
+ // Render commands from JSON data
+ function renderCommands(commandsData) {
+ const container = document.getElementById('container');
+ const quickNav = document.getElementById('quickNav');
+
+ // Group commands by category
+ const categories = {};
+ for (const cmd of commandsData) {
+ if (!categories[cmd.category]) {
+ categories[cmd.category] = [];
+ }
+ categories[cmd.category].push(cmd);
+ }
+
+ // Clear container
+ container.innerHTML = '';
+ quickNav.innerHTML = '';
+
+ // Add quick navigation links
+ const sortedCategories = Object.keys(categories).sort();
+ for (const category of sortedCategories) {
+ const categoryId = category.toLowerCase().replace(/\s+/g, '-');
+ const link = document.createElement('a');
+ link.href = `#${categoryId}`;
+ link.textContent = category;
+ quickNav.appendChild(link);
+ }
+
+ // Render each category
+ for (const category of sortedCategories) {
+ const commands = categories[category];
+ const categoryId = category.toLowerCase().replace(/\s+/g, '-');
+
+ const categoryDiv = document.createElement('div');
+ categoryDiv.className = 'category';
+ categoryDiv.id = categoryId;
+ categoryDiv.dataset.category = categoryId;
+
+ const categoryHeader = document.createElement('div');
+ categoryHeader.className = 'category-header';
+ categoryHeader.textContent = category;
+ categoryDiv.appendChild(categoryHeader);
+
+ const categoryContent = document.createElement('div');
+ categoryContent.className = 'category-content';
+
+ // Render each command in category
+ for (const cmd of commands) {
+ const commandDiv = document.createElement('div');
+ commandDiv.className = 'command';
+ commandDiv.dataset.command = cmd.command;
+
+ // Command header
+ const commandHeader = document.createElement('div');
+ commandHeader.className = 'command-header';
+ commandHeader.onclick = function() { toggleCommand(this); };
+
+ const commandTitle = document.createElement('div');
+ commandTitle.className = 'command-title';
+
+ const commandName = document.createElement('div');
+ commandName.className = 'command-name';
+ commandName.textContent = cmd.command;
+
+ // Add role badge if required
+ if (cmd.required_role) {
+ const roleBadge = document.createElement('span');
+ roleBadge.className = `role-badge ${cmd.required_role.toLowerCase()}`;
+ roleBadge.textContent = cmd.required_role;
+ commandName.appendChild(roleBadge);
+ }
+
+ commandTitle.appendChild(commandName);
+
+ if (cmd.summary) {
+ const commandSummary = document.createElement('div');
+ commandSummary.className = 'command-summary';
+ commandSummary.textContent = cmd.summary;
+ commandTitle.appendChild(commandSummary);
+ }
+
+ commandHeader.appendChild(commandTitle);
+
+ const expandIcon = document.createElement('div');
+ expandIcon.className = 'command-expand-icon';
+ expandIcon.textContent = '▼';
+ commandHeader.appendChild(expandIcon);
+
+ commandDiv.appendChild(commandHeader);
+
+ // Command details
+ const commandDetails = document.createElement('div');
+ commandDetails.className = 'command-details';
+
+ // Description
+ if (cmd.description && cmd.description !== cmd.summary) {
+ const descDiv = document.createElement('div');
+ descDiv.className = 'command-description';
+ descDiv.textContent = cmd.description;
+ commandDetails.appendChild(descDiv);
+ }
+
+ // Return type
+ const returnTypeDiv = document.createElement('div');
+ returnTypeDiv.className = 'return-type';
+ returnTypeDiv.innerHTML = `
+ <span class="return-type-label">Returns:</span>
+ <span class="return-type-value">${makeTypeLinks(cmd.return_type)}</span>
+ `;
+ commandDetails.appendChild(returnTypeDiv);
+
+ // Parameters
+ if (cmd.parameters && cmd.parameters.length > 0) {
+ const paramsSection = document.createElement('div');
+ paramsSection.className = 'params-section';
+
+ const paramsTitle = document.createElement('div');
+ paramsTitle.className = 'params-title';
+ paramsTitle.textContent = 'Parameters:';
+ paramsSection.appendChild(paramsTitle);
+
+ for (const param of cmd.parameters) {
+ const paramDiv = document.createElement('div');
+ paramDiv.className = 'param';
+
+ const typeHtml = makeTypeLinks(param.type, true);
+
+ let paramHtml = `<span class="param-name">${escapeHtml(param.name)}</span>`;
+ if (param.required) {
+ paramHtml += '<span class="param-required">REQUIRED</span>';
+ }
+
+ // If it's a list format, display it differently
+ if (typeHtml.includes('<ul>')) {
+ paramHtml += `<div class="param-type-union">${typeHtml}</div>`;
+ } else {
+ paramHtml += `<span class="param-type">${typeHtml}</span>`;
+ }
+
+ if (param.description) {
+ paramHtml += `<div class="param-description">${escapeHtml(param.description)}</div>`;
+ }
+
+ paramDiv.innerHTML = paramHtml;
+ paramsSection.appendChild(paramDiv);
+ }
+
+ commandDetails.appendChild(paramsSection);
+ }
+
+ // Build example request body
+ const exampleArgs = buildExampleArgs(cmd.parameters || []);
+ const requestBody = { command: cmd.command };
+ if (Object.keys(exampleArgs).length > 0) {
+ requestBody.args = exampleArgs;
+ }
+
+ // Build cURL command
+ let curlHeaders = ' -H "Content-Type: application/json"';
+ if (cmd.authenticated) {
+ curlHeaders += ' \\\n -H "Authorization: Bearer YOUR_ACCESS_TOKEN"';
+ }
+
+ const curlCmd = `curl -X POST ${SERVER_URL}/api \\\n${curlHeaders} \\\n -d '${JSON.stringify(requestBody, null, 2)}'`;
+
+ // Add tabs
+ const tabsDiv = document.createElement('div');
+ tabsDiv.className = 'tabs';
+
+ const tabButtons = document.createElement('div');
+ tabButtons.className = 'tab-buttons';
+
+ const curlTabBtn = document.createElement('button');
+ curlTabBtn.className = 'tab-btn active';
+ curlTabBtn.textContent = 'cURL';
+ curlTabBtn.onclick = function() { switchTab(this, `curl-${cmd.command.replace(/\//g, '-')}`); };
+ tabButtons.appendChild(curlTabBtn);
+
+ const tryitTabBtn = document.createElement('button');
+ tryitTabBtn.className = 'tab-btn';
+ tryitTabBtn.textContent = 'Try It';
+ tryitTabBtn.onclick = function() { switchTab(this, `tryit-${cmd.command.replace(/\//g, '-')}`); };
+ tabButtons.appendChild(tryitTabBtn);
+
+ tabsDiv.appendChild(tabButtons);
+
+ // cURL tab
+ const curlTabContent = document.createElement('div');
+ curlTabContent.id = `curl-${cmd.command.replace(/\//g, '-')}`;
+ curlTabContent.className = 'tab-content active';
+
+ const exampleDiv = document.createElement('div');
+ exampleDiv.className = 'example';
+
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'copy-btn';
+ copyBtn.textContent = 'Copy';
+ copyBtn.onclick = function() { copyCode(this); };
+ exampleDiv.appendChild(copyBtn);
+
+ const pre = document.createElement('pre');
+ pre.textContent = curlCmd;
+ exampleDiv.appendChild(pre);
+
+ curlTabContent.appendChild(exampleDiv);
+ tabsDiv.appendChild(curlTabContent);
+
+ // Try It tab
+ const tryitTabContent = document.createElement('div');
+ tryitTabContent.id = `tryit-${cmd.command.replace(/\//g, '-')}`;
+ tryitTabContent.className = 'tab-content';
+
+ const tryItSection = document.createElement('div');
+ tryItSection.className = 'try-it-section';
+
+ const jsonInput = document.createElement('textarea');
+ jsonInput.className = 'json-input';
+ jsonInput.value = JSON.stringify(requestBody, null, 2);
+ tryItSection.appendChild(jsonInput);
+
+ const tryBtn = document.createElement('button');
+ tryBtn.className = 'try-btn';
+ tryBtn.textContent = 'Execute';
+ tryBtn.onclick = function() { tryCommand(this, cmd.command); };
+ tryItSection.appendChild(tryBtn);
+
+ const responseOutput = document.createElement('div');
+ responseOutput.className = 'response-output';
+ tryItSection.appendChild(responseOutput);
+
+ tryitTabContent.appendChild(tryItSection);
+ tabsDiv.appendChild(tryitTabContent);
+
+ commandDetails.appendChild(tabsDiv);
+ commandDiv.appendChild(commandDetails);
+ categoryContent.appendChild(commandDiv);
+ }
+
+ categoryDiv.appendChild(categoryContent);
+ container.appendChild(categoryDiv);
+ }
+
+ // Update cURL examples if already authenticated
+ updateCurlExamples();
+ }
+
+ // Load commands from JSON endpoint
+ async function loadCommands() {
+ try {
+ const response = await fetch('/api-docs/commands.json');
+ if (!response.ok) {
+ throw new Error('Failed to load commands');
+ }
+ const commandsData = await response.json();
+ renderCommands(commandsData);
+ } catch (error) {
+ document.getElementById('container').innerHTML =
+ `<div class="loading">Error loading commands: ${escapeHtml(error.message)}</div>`;
+ }
+ }
+
+ // Search functionality
+ document.getElementById('search').addEventListener('input', function(e) {
+ const searchTerm = e.target.value.toLowerCase();
+ const commands = document.querySelectorAll('.command');
+ const categories = document.querySelectorAll('.category');
+
+ commands.forEach(command => {
+ const commandName = command.dataset.command;
+ const commandText = command.textContent.toLowerCase();
+ if (commandName.includes(searchTerm) || commandText.includes(searchTerm)) {
+ command.classList.remove('hidden');
+ } else {
+ command.classList.add('hidden');
+ }
+ });
+
+ // Hide empty categories
+ categories.forEach(category => {
+ const visibleCommands = category.querySelectorAll('.command:not(.hidden)');
+ if (visibleCommands.length === 0) {
+ category.classList.add('hidden');
+ } else {
+ category.classList.remove('hidden');
+ }
+ });
+ });
+
+ // Toggle command details
+ function toggleCommand(header) {
+ const command = header.parentElement;
+ const details = command.querySelector('.command-details');
+ const icon = header.querySelector('.command-expand-icon');
+
+ details.classList.toggle('show');
+ icon.classList.toggle('expanded');
+ }
+
+ // Copy to clipboard
+ function copyCode(button) {
+ const code = button.nextElementSibling.textContent;
+ navigator.clipboard.writeText(code).then(() => {
+ const originalText = button.textContent;
+ button.textContent = 'Copied!';
+ setTimeout(() => {
+ button.textContent = originalText;
+ }, 2000);
+ });
+ }
+
+ // Tab switching
+ function switchTab(button, tabId) {
+ const tabButtons = button.parentElement;
+ const tabs = tabButtons.parentElement;
+
+ // Remove active class from all buttons and tabs
+ tabButtons.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.classList.remove('active');
+ });
+ tabs.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.remove('active');
+ });
+
+ // Add active class to clicked button and corresponding tab
+ button.classList.add('active');
+ document.getElementById(tabId).classList.add('active');
+ }
+
+ // Try command functionality
+ async function tryCommand(button, commandName) {
+ const section = button.parentElement;
+ const textarea = section.querySelector('.json-input');
+ const output = section.querySelector('.response-output');
+
+ // Disable button while processing
+ button.disabled = true;
+ button.textContent = 'Executing...';
+
+ // Clear previous output
+ output.className = 'response-output show';
+ output.textContent = 'Loading...';
+
+ try {
+ // Parse JSON from textarea
+ let requestBody;
+ try {
+ requestBody = JSON.parse(textarea.value);
+ } catch (e) {
+ throw new Error('Invalid JSON: ' + e.message);
+ }
+
+ // Get stored token
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+
+ // Build headers
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ // Add authorization header if token exists
+ if (token) {
+ headers['Authorization'] = 'Bearer ' + token;
+ }
+
+ // Make API request
+ const response = await fetch('/api', {
+ method: 'POST',
+ headers: headers,
+ body: JSON.stringify(requestBody)
+ });
+
+ let result;
+ const contentType = response.headers.get('content-type');
+ if (contentType && contentType.includes('application/json')) {
+ result = await response.json();
+ } else {
+ const text = await response.text();
+ result = { error: text };
+ }
+
+ // Display result
+ if (response.ok) {
+ output.className = 'response-output show success';
+ output.textContent = 'Success!\n\n' + JSON.stringify(result, null, 2);
+ } else {
+ output.className = 'response-output show error';
+
+ // Handle 401/403 - token invalid or revoked
+ if (response.status === 401 || response.status === 403) {
+ clearAuth();
+ output.textContent = 'Authentication Error: Your session has expired '
+ + 'or token was revoked. Please login again.';
+ } else {
+ // Try to extract a meaningful error message
+ let errorMsg = 'Request failed';
+ if (result.error) {
+ errorMsg = result.error;
+ } else if (result.message) {
+ errorMsg = result.message;
+ } else if (typeof result === 'string') {
+ errorMsg = result;
+ } else {
+ errorMsg = JSON.stringify(result, null, 2);
+ }
+ output.textContent = 'Error: ' + errorMsg;
+ }
+ }
+ } catch (error) {
+ output.className = 'response-output show error';
+ // Provide more user-friendly error messages
+ if (error.message.includes('Invalid JSON')) {
+ output.textContent = 'JSON Syntax Error: Please check your request format. '
+ + error.message;
+ } else if (error.message.includes('Failed to fetch')) {
+ output.textContent = 'Connection Error: Unable to reach the API server. '
+ + 'Please check if the server is running.';
+ } else {
+ output.textContent = 'Error: ' + error.message;
+ }
+ } finally {
+ button.disabled = false;
+ button.textContent = 'Execute';
+ }
+ }
+
+ // Load commands on page load
+ loadCommands();
+ </script>
+</body>
+</html>
--- /dev/null
+/* Music Assistant - Common Styles
+ * Shared CSS variables and base styles used across all HTML pages
+ */
+
+/* CSS Variables for theming */
+:root {
+ --fg: #000000;
+ --background: #f5f5f5;
+ --overlay: #e7e7e7;
+ --panel: #ffffff;
+ --default: #ffffff;
+ --primary: #03a9f4;
+ --text-secondary: rgba(0, 0, 0, 0.6);
+ --text-tertiary: rgba(0, 0, 0, 0.4);
+ --border: rgba(0, 0, 0, 0.1);
+ --input-bg: rgba(0, 0, 0, 0.03);
+ --input-focus-bg: rgba(3, 169, 244, 0.05);
+ --primary-glow: rgba(3, 169, 244, 0.15);
+ --error-bg: rgba(244, 67, 54, 0.08);
+ --error-border: rgba(244, 67, 54, 0.2);
+ --error-text: #d32f2f;
+ --success: #4caf50;
+ --success-bg: rgba(76, 175, 80, 0.1);
+ --success-border: rgba(76, 175, 80, 0.3);
+ --code-bg: #2d2d2d;
+ --code-fg: #f8f8f2;
+ --code-comment: #75715e;
+ --code-string: #e6db74;
+ --code-keyword: #66d9ef;
+ --info-bg: rgba(3, 169, 244, 0.08);
+ --info-border: rgba(3, 169, 244, 0.3);
+}
+
+/* Dark mode color scheme */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --fg: #ffffff;
+ --background: #181818;
+ --overlay: #181818;
+ --panel: #232323;
+ --default: #000000;
+ --text-secondary: rgba(255, 255, 255, 0.7);
+ --text-tertiary: rgba(255, 255, 255, 0.4);
+ --border: rgba(255, 255, 255, 0.08);
+ --input-bg: rgba(255, 255, 255, 0.05);
+ --input-focus-bg: rgba(3, 169, 244, 0.08);
+ --primary-glow: rgba(3, 169, 244, 0.25);
+ --error-bg: rgba(244, 67, 54, 0.1);
+ --error-border: rgba(244, 67, 54, 0.25);
+ --error-text: #ff6b6b;
+ --success: #66bb6a;
+ --success-bg: rgba(102, 187, 106, 0.15);
+ --success-border: rgba(102, 187, 106, 0.4);
+ --code-bg: #1a1a1a;
+ --info-bg: rgba(3, 169, 244, 0.12);
+ --info-border: rgba(3, 169, 244, 0.4);
+ }
+}
+
+/* Base reset and body styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background: var(--background);
+ color: var(--fg);
+ line-height: 1.6;
+}
+
+/* Common header styles */
+.header {
+ background: var(--panel);
+ color: var(--fg);
+ padding: 1.5rem 2rem;
+ text-align: center;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ border-bottom: 1px solid var(--border);
+}
+
+.header h1 {
+ font-size: 1.8em;
+ margin-bottom: 0.3rem;
+ font-weight: 600;
+}
+
+.header p {
+ font-size: 0.95em;
+ opacity: 0.9;
+}
+
+.header .logo {
+ margin-bottom: 1rem;
+}
+
+.header .logo img {
+ width: 60px;
+ height: 60px;
+ object-fit: contain;
+}
+
+/* Logo styles */
+.logo {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.logo img {
+ width: 72px;
+ height: 72px;
+ object-fit: contain;
+}
+
+/* Form elements */
+.form-group {
+ margin-bottom: 22px;
+}
+
+label {
+ display: block;
+ color: var(--fg);
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 8px;
+ letter-spacing: 0.2px;
+}
+
+input[type="text"],
+input[type="password"] {
+ width: 100%;
+ padding: 14px 16px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ font-size: 15px;
+ color: var(--fg);
+ transition: all 0.2s ease;
+}
+
+input[type="text"]::placeholder,
+input[type="password"]::placeholder {
+ color: var(--text-tertiary);
+}
+
+input[type="text"]:focus,
+input[type="password"]:focus {
+ outline: none;
+ border-color: var(--primary);
+ background: var(--input-focus-bg);
+ box-shadow: 0 0 0 3px var(--primary-glow);
+}
+
+input[type="text"]:disabled {
+ background: var(--overlay);
+ color: var(--text-tertiary);
+ cursor: not-allowed;
+}
+
+/* Button styles */
+.btn {
+ width: 100%;
+ padding: 15px;
+ border: none;
+ border-radius: 10px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ letter-spacing: 0.3px;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ filter: brightness(1.1);
+ box-shadow: 0 8px 24px var(--primary-glow);
+ transform: translateY(-1px);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+ filter: brightness(0.95);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+ filter: none;
+}
+
+.btn-secondary {
+ background: var(--input-bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.btn-secondary:hover {
+ background: var(--input-focus-bg);
+ border-color: var(--primary);
+}
+
+/* Error and success messages */
+.error {
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ color: var(--error-text);
+ padding: 14px 16px;
+ border-radius: 10px;
+ margin-bottom: 22px;
+ font-size: 13px;
+ display: none;
+}
+
+.error.show {
+ display: block;
+}
+
+.error-message {
+ background: var(--error-bg);
+ color: var(--error-text);
+ padding: 14px 16px;
+ border-radius: 10px;
+ margin-bottom: 22px;
+ font-size: 13px;
+ display: none;
+ border: 1px solid var(--error-border);
+}
+
+.error-message.show {
+ display: block;
+}
+
+/* Container and panel styles */
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ background: var(--panel);
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border);
+}
+
+.panel {
+ background: var(--panel);
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border);
+ padding: 48px 40px;
+}
+
+/* Loading indicator */
+.loading {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Code blocks */
+.code-block,
+.example {
+ background: var(--code-bg);
+ color: var(--code-fg);
+ padding: 1rem;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin: 1rem 0;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.9em;
+ line-height: 1.6;
+}
+
+.example pre {
+ margin: 0;
+}
+
+/* Divider */
+.divider {
+ text-align: center;
+ margin: 28px 0;
+ position: relative;
+}
+
+.divider::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: var(--border);
+}
+
+.divider span {
+ background: var(--panel);
+ padding: 0 16px;
+ color: var(--text-tertiary);
+ font-size: 13px;
+ position: relative;
+}
+
+/* Link styles */
+.type-link {
+ color: var(--primary);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--primary);
+ transition: all 0.2s;
+}
+
+.type-link:hover {
+ opacity: 0.8;
+ border-bottom-color: transparent;
+}
+
+.back-link {
+ display: inline-block;
+ margin-bottom: 1rem;
+ padding: 0.5rem 1rem;
+ background: var(--primary);
+ color: #ffffff;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: background 0.2s;
+}
+
+.back-link:hover {
+ opacity: 0.9;
+}
+
+/* Utility classes */
+.hidden {
+ display: none;
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login - Music Assistant</title>
+ <link rel="stylesheet" href="resources/common.css">
+ <style>
+ body {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ }
+
+ .login-container {
+ background: var(--panel);
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12),
+ 0 0 0 1px var(--border);
+ width: 100%;
+ max-width: 400px;
+ padding: 48px 40px;
+ }
+
+ h1 {
+ text-align: center;
+ color: var(--fg);
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.5px;
+ margin-bottom: 8px;
+ }
+
+ .subtitle {
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: 14px;
+ margin-bottom: 32px;
+ }
+
+ .oauth-providers {
+ margin-top: 8px;
+ }
+
+ .provider-icon {
+ width: 20px;
+ height: 20px;
+ }
+ </style>
+</head>
+<body>
+ <div class="login-container">
+ <div class="logo">
+ <img src="logo.png" alt="Music Assistant">
+ </div>
+
+ <h1>Music Assistant</h1>
+ <p class="subtitle">Sign in to continue</p>
+
+ <div id="error" class="error"></div>
+
+ <form id="loginForm">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
+ </div>
+
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" required placeholder="Enter your password">
+ </div>
+
+ <button type="submit" class="btn btn-primary" id="loginBtn">
+ <span id="loginText">Sign In</span>
+ <span id="loginLoading" class="loading" style="display: none;"></span>
+ </button>
+ </form>
+
+ <div id="oauthProviders" class="oauth-providers">
+ <!-- OAuth providers will be inserted here -->
+ </div>
+ </div>
+
+ <script>
+ const API_BASE = window.location.origin;
+
+ // Get return_url and device_name from query string
+ const urlParams = new URLSearchParams(window.location.search);
+ const returnUrl = urlParams.get('return_url');
+ const deviceName = urlParams.get('device_name');
+
+ // Validate URL to prevent XSS attacks
+ // Note: Server-side validation is the primary security layer
+ function isValidRedirectUrl(url) {
+ if (!url) return false;
+ try {
+ const parsed = new URL(url, window.location.origin);
+ // Allow http, https, and custom mobile app schemes
+ const allowedProtocols = ['http:', 'https:', 'musicassistant:'];
+ return allowedProtocols.includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+ }
+
+ // Show error message
+ function showError(message) {
+ const errorEl = document.getElementById('error');
+ errorEl.textContent = message;
+ errorEl.classList.add('show');
+ }
+
+ // Hide error message
+ function hideError() {
+ document.getElementById('error').classList.remove('show');
+ }
+
+ // Set loading state
+ function setLoading(loading) {
+ const btn = document.getElementById('loginBtn');
+ const text = document.getElementById('loginText');
+ const loadingEl = document.getElementById('loginLoading');
+
+ btn.disabled = loading;
+ text.style.display = loading ? 'none' : 'inline';
+ loadingEl.style.display = loading ? 'inline-block' : 'none';
+ }
+
+ // Load OAuth providers
+ async function loadProviders() {
+ try {
+ const response = await fetch(`${API_BASE}/auth/providers`);
+ const providers = await response.json();
+
+ const oauthProviders = providers.filter(p => p.requires_redirect && p.provider_type !== 'builtin');
+
+ if (oauthProviders.length > 0) {
+ const container = document.getElementById('oauthProviders');
+
+ // Add divider
+ const divider = document.createElement('div');
+ divider.className = 'divider';
+ divider.innerHTML = '<span>Or continue with</span>';
+ container.appendChild(divider);
+
+ // Add OAuth buttons
+ oauthProviders.forEach(provider => {
+ const btn = document.createElement('button');
+ btn.className = 'btn btn-secondary';
+ btn.type = 'button';
+
+ let providerName = provider.provider_type;
+ if (provider.provider_type === 'homeassistant') {
+ providerName = 'Home Assistant';
+ } else if (provider.provider_type === 'google') {
+ providerName = 'Google';
+ }
+
+ btn.innerHTML = `<span>Sign in with ${providerName}</span>`;
+ btn.onclick = () => initiateOAuth(provider.provider_id);
+
+ container.appendChild(btn);
+ });
+ }
+ } catch (error) {
+ console.error('Failed to load providers:', error);
+ }
+ }
+
+ // Handle form submission
+ document.getElementById('loginForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ hideError();
+ setLoading(true);
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ try {
+ const requestBody = {
+ provider_id: 'builtin',
+ credentials: { username, password }
+ };
+
+ // Include device_name if provided via query parameter
+ if (deviceName) {
+ requestBody.device_name = deviceName;
+ }
+
+ // Include return_url if present
+ if (returnUrl) {
+ requestBody.return_url = returnUrl;
+ }
+
+ const response = await fetch(`${API_BASE}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(requestBody)
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ if (data.redirect_to) {
+ window.location.href = data.redirect_to;
+ } else {
+ window.location.href = `/?code=${encodeURIComponent(data.token)}`;
+ }
+ } else {
+ showError(data.error || 'Login failed');
+ }
+ } catch (error) {
+ showError('Network error. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ // Initiate OAuth flow
+ async function initiateOAuth(providerId) {
+ try {
+ // Include provider_id in callback URL
+ let authorizeUrl = `${API_BASE}/auth/authorize?provider_id=${providerId}`;
+
+ // Pass return_url to authorize endpoint if present
+ if (returnUrl) {
+ authorizeUrl += `&return_url=${encodeURIComponent(returnUrl)}`;
+ }
+
+ const response = await fetch(authorizeUrl);
+ const data = await response.json();
+
+ if (data.authorization_url) {
+ // Open OAuth flow in popup window
+ const width = 600;
+ const height = 700;
+ const left = (screen.width - width) / 2;
+ const top = (screen.height - height) / 2;
+ const popup = window.open(
+ data.authorization_url,
+ 'oauth_popup',
+ `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
+ );
+
+ if (!popup) {
+ showError('Failed to open popup. Please allow popups for this site.');
+ }
+ } else {
+ showError('Failed to initiate OAuth flow');
+ }
+ } catch (error) {
+ showError('Network error. Please try again.');
+ }
+ }
+
+ // Listen for OAuth success messages from popup window
+ window.addEventListener('message', (event) => {
+ // Verify message is from our origin
+ if (event.origin !== window.location.origin) {
+ return;
+ }
+
+ // Check if it's an OAuth success message
+ if (event.data && event.data.type === 'oauth_success' && event.data.token) {
+ // Store token in localStorage
+ localStorage.setItem('auth_token', event.data.token);
+
+ // Redirect to the URL from OAuth callback
+ const redirectUrl = event.data.redirectUrl || `/?token=${encodeURIComponent(event.data.token)}`;
+ if (isValidRedirectUrl(redirectUrl)) {
+ window.location.href = redirectUrl;
+ } else {
+ window.location.href = `/?token=${encodeURIComponent(event.data.token)}`;
+ }
+ }
+ });
+
+ // Clear error on input
+ document.querySelectorAll('input').forEach(input => {
+ input.addEventListener('input', hideError);
+ });
+
+ // Load providers on page load
+ loadProviders();
+ </script>
+</body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login Successful</title>
+ <link rel="stylesheet" href="../resources/common.css">
+ <style>
+ body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 20px;
+ }
+
+ .consent-banner {
+ display: none;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ border-left: 4px solid var(--error-text);
+ padding: 20px;
+ margin-bottom: 20px;
+ border-radius: 10px;
+ max-width: 500px;
+ width: 100%;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border);
+ }
+
+ .consent-banner.show {
+ display: block;
+ }
+
+ .consent-banner h2 {
+ color: var(--error-text);
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 10px 0;
+ }
+
+ .consent-banner p {
+ color: var(--fg);
+ font-size: 14px;
+ margin: 0 0 8px 0;
+ line-height: 1.5;
+ }
+
+ .consent-banner .domain {
+ font-weight: 600;
+ font-family: 'Monaco', 'Menlo', monospace;
+ background: var(--input-bg);
+ padding: 2px 6px;
+ border-radius: 4px;
+ }
+
+ .consent-banner .buttons {
+ margin-top: 16px;
+ display: flex;
+ gap: 10px;
+ }
+
+ .consent-banner button {
+ flex: 1;
+ padding: 10px;
+ border: none;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .consent-banner .btn-approve {
+ background: var(--primary);
+ color: white;
+ }
+
+ .consent-banner .btn-approve:hover {
+ filter: brightness(1.1);
+ }
+
+ .consent-banner .btn-cancel {
+ background: var(--input-bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ }
+
+ .consent-banner .btn-cancel:hover {
+ background: var(--input-focus-bg);
+ }
+
+ .callback-container {
+ background: var(--panel);
+ padding: 48px 40px;
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border);
+ text-align: center;
+ max-width: 500px;
+ width: 100%;
+ }
+
+ h1 {
+ color: var(--fg);
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.5px;
+ margin-bottom: 12px;
+ }
+
+ p {
+ color: var(--text-secondary);
+ font-size: 15px;
+ }
+ </style>
+</head>
+<body>
+ <div class="consent-banner" id="consentBanner">
+ <h2>⚠️ External Application Authorization</h2>
+ <p>The application at <span class="domain" id="redirectDomain"></span> is requesting access to your Music Assistant instance.</p>
+ <p>Only authorize if you trust this application.</p>
+ <div class="buttons">
+ <button class="btn-cancel" onclick="denyRedirect()">Cancel</button>
+ <button class="btn-approve" onclick="approveRedirect()">Authorize & Continue</button>
+ </div>
+ </div>
+ <div class="callback-container">
+ <h1 id="status">Login Successful!</h1>
+ <p id="message">Redirecting...</p>
+ </div>
+ <script>
+ const statusEl = document.getElementById('status');
+ const messageEl = document.getElementById('message');
+ const token = '{TOKEN}';
+ const redirectUrl = '{REDIRECT_URL}';
+ const requiresConsent = {REQUIRES_CONSENT};
+
+ // Validate URL to prevent XSS attacks
+ // Note: Server-side validation is the primary security layer
+ function isValidRedirectUrl(url) {
+ if (!url) return false;
+ try {
+ const parsed = new URL(url, window.location.origin);
+ // Allow http, https, and custom mobile app schemes
+ const allowedProtocols = ['http:', 'https:', 'musicassistant:'];
+ return allowedProtocols.includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+ }
+
+ function performRedirect() {
+ const isPopup = window.opener !== null;
+
+ if (isPopup) {
+ // Popup mode - send token to parent and close
+ statusEl.textContent = 'Login Complete!';
+ messageEl.textContent = 'Closing popup...';
+
+ if (window.opener && !window.opener.closed) {
+ try {
+ window.opener.postMessage({
+ type: 'oauth_success',
+ token: token,
+ redirectUrl: redirectUrl
+ }, window.location.origin);
+ } catch (e) {
+ console.error('Failed to send postMessage:', e);
+ }
+ }
+
+ setTimeout(() => {
+ window.close();
+ setTimeout(() => {
+ messageEl.textContent = 'Please close this window manually and return to the login page.';
+ }, 500);
+ }, 1000);
+ } else {
+ // Same window mode - redirect directly
+ localStorage.setItem('auth_token', token);
+ if (isValidRedirectUrl(redirectUrl)) {
+ window.location.href = redirectUrl;
+ } else {
+ window.location.href = '/';
+ }
+ }
+ }
+
+ function approveRedirect() {
+ document.getElementById('consentBanner').classList.remove('show');
+ performRedirect();
+ }
+
+ function denyRedirect() {
+ window.location.href = '/';
+ }
+
+ // Check if consent is required
+ if (requiresConsent) {
+ // Show consent banner with domain
+ try {
+ const parsed = new URL(redirectUrl);
+ document.getElementById('redirectDomain').textContent = parsed.origin;
+ } catch {
+ document.getElementById('redirectDomain').textContent = 'unknown';
+ }
+ document.getElementById('consentBanner').classList.add('show');
+ statusEl.textContent = 'Authorization Required';
+ messageEl.textContent = 'Please review the authorization request above.';
+ } else {
+ // Trusted domain, proceed with redirect
+ performRedirect();
+ }
+ </script>
+</body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Music Assistant API - Schemas Reference</title>
+ <link rel="stylesheet" href="../resources/common.css">
+ <style>
+ .nav-container {
+ background: var(--panel);
+ padding: 1rem 2rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+ .search-box input {
+ width: 100%;
+ max-width: 600px;
+ padding: 0.6rem 1rem;
+ font-size: 0.95em;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ display: block;
+ margin: 0 auto;
+ background: var(--panel);
+ color: var(--fg);
+ }
+ .search-box input:focus {
+ outline: none;
+ border-color: var(--primary);
+ }
+ .container {
+ max-width: 1200px;
+ margin: 2rem auto;
+ padding: 0 2rem;
+ }
+ .loading {
+ text-align: center;
+ padding: 3rem;
+ color: var(--text-secondary);
+ font-size: 1.1em;
+ }
+ .error {
+ text-align: center;
+ padding: 3rem;
+ color: #e74c3c;
+ font-size: 1.1em;
+ }
+ .schema {
+ background: var(--panel);
+ margin-bottom: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.08);
+ overflow: hidden;
+ scroll-margin-top: 100px;
+ }
+ .schema-header {
+ background: var(--primary);
+ color: var(--fg);
+ padding: 1rem 1.5rem;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .schema-header:hover {
+ opacity: 0.95;
+ }
+ .schema-name {
+ font-size: 1.3em;
+ font-weight: 600;
+ font-family: 'Monaco', 'Courier New', monospace;
+ }
+ .schema-expand-icon {
+ font-size: 1.2em;
+ transition: transform 0.3s;
+ }
+ .schema-expand-icon.expanded {
+ transform: rotate(180deg);
+ }
+ .schema-content {
+ padding: 1.5rem;
+ display: none;
+ }
+ .schema-content.show {
+ display: block;
+ }
+ .schema-description {
+ color: var(--text-secondary);
+ margin-bottom: 1rem;
+ font-style: italic;
+ }
+ .properties-section {
+ margin-top: 1rem;
+ }
+ .properties-title {
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ font-size: 1.1em;
+ }
+ .property {
+ background: var(--background);
+ padding: 0.75rem 1rem;
+ margin: 0.5rem 0;
+ border-radius: 6px;
+ border-left: 3px solid var(--primary);
+ }
+ .property-name {
+ font-family: 'Monaco', 'Courier New', monospace;
+ color: var(--primary);
+ font-weight: 600;
+ font-size: 1em;
+ }
+ .property-required {
+ display: inline-block;
+ background: #e74c3c;
+ color: #ffffff;
+ padding: 0.15rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75em;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ }
+ .property-optional {
+ display: inline-block;
+ background: #95a5a6;
+ color: #ffffff;
+ padding: 0.15rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75em;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ }
+ .property-nullable {
+ display: inline-block;
+ background: #f39c12;
+ color: #ffffff;
+ padding: 0.15rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75em;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ }
+ .property-type {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ margin-left: 0.5rem;
+ font-family: 'Monaco', 'Courier New', monospace;
+ }
+ .property-description {
+ color: var(--text-secondary);
+ margin-top: 0.25rem;
+ font-size: 0.95em;
+ }
+ .type-link {
+ color: var(--primary);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--primary);
+ transition: all 0.2s;
+ }
+ .type-link:hover {
+ opacity: 0.8;
+ border-bottom-color: transparent;
+ }
+ .hidden {
+ display: none;
+ }
+ .back-link {
+ display: inline-block;
+ margin-bottom: 1rem;
+ padding: 0.5rem 1rem;
+ background: var(--primary);
+ color: #ffffff;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: background 0.2s;
+ }
+ .back-link:hover {
+ opacity: 0.9;
+ }
+ .openapi-link {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ background: #2e7d32;
+ color: #ffffff;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: background 0.2s;
+ }
+ .openapi-link:hover {
+ background: #1b5e20;
+ }
+ .enum-values {
+ margin-top: 0.5rem;
+ padding: 0.5rem;
+ background: var(--panel);
+ border-radius: 4px;
+ }
+ .enum-values-title {
+ font-weight: 600;
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ margin-bottom: 0.25rem;
+ }
+ .enum-value {
+ display: inline-block;
+ padding: 0.2rem 0.5rem;
+ margin: 0.2rem;
+ background: var(--success-bg);
+ border: 1px solid var(--success-border);
+ border-radius: 4px;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.85em;
+ color: var(--success);
+ }
+ .header .logo {
+ margin-bottom: 1rem;
+ }
+ .header .logo img {
+ width: 60px;
+ height: 60px;
+ object-fit: contain;
+ }
+ .array-type {
+ font-style: italic;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <div class="logo">
+ <img src="/logo.png" alt="Music Assistant">
+ </div>
+ <h1>Schemas Reference</h1>
+ <p>Data models and types used in the Music Assistant API</p>
+ </div>
+
+ <div class="nav-container">
+ <div class="search-box">
+ <input type="text" id="search" placeholder="Search schemas..." />
+ </div>
+ </div>
+
+ <div class="container">
+ <a href="/api-docs" class="back-link">← Back to API Documentation</a>
+ <div id="schemas-container">
+ <div class="loading">Loading schemas...</div>
+ </div>
+ <div style="text-align: center; margin-top: 3rem; padding: 2rem 0;">
+ <a href="/api-docs/openapi.json" class="openapi-link" download>
+ Download OpenAPI Spec
+ </a>
+ </div>
+ </div>
+
+ <script>
+ // Fetch schemas from API and render them
+ async function loadSchemas() {
+ const container = document.getElementById('schemas-container');
+
+ try {
+ const response = await fetch('/api-docs/schemas.json');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const schemas = await response.json();
+
+ // Clear loading message
+ container.innerHTML = '';
+
+ // Render each schema
+ const sortedSchemaNames = Object.keys(schemas).sort();
+ sortedSchemaNames.forEach(schemaName => {
+ const schemaElement = createSchemaElement(schemaName, schemas[schemaName]);
+ container.appendChild(schemaElement);
+ });
+
+ // Handle deep linking after schemas are loaded
+ handleDeepLinking();
+
+ } catch (error) {
+ console.error('Error loading schemas:', error);
+ container.innerHTML = '<div class="error">Failed to load schemas. Please try again later.</div>';
+ }
+ }
+
+ function createSchemaElement(schemaName, schemaDef) {
+ const schemaDiv = document.createElement('div');
+ schemaDiv.className = 'schema';
+ schemaDiv.id = `schema-${schemaName}`;
+ schemaDiv.setAttribute('data-schema', schemaName);
+
+ // Schema header
+ const headerDiv = document.createElement('div');
+ headerDiv.className = 'schema-header';
+ headerDiv.onclick = function() { toggleSchema(this); };
+
+ const nameDiv = document.createElement('div');
+ nameDiv.className = 'schema-name';
+ nameDiv.textContent = schemaName;
+
+ const iconDiv = document.createElement('div');
+ iconDiv.className = 'schema-expand-icon';
+ iconDiv.textContent = '▼';
+
+ headerDiv.appendChild(nameDiv);
+ headerDiv.appendChild(iconDiv);
+ schemaDiv.appendChild(headerDiv);
+
+ // Schema content
+ const contentDiv = document.createElement('div');
+ contentDiv.className = 'schema-content';
+
+ // Add description if available
+ if (schemaDef.description) {
+ const descDiv = document.createElement('div');
+ descDiv.className = 'schema-description';
+ descDiv.textContent = schemaDef.description;
+ contentDiv.appendChild(descDiv);
+ }
+
+ // Add properties if available
+ if (schemaDef.properties) {
+ const propertiesSection = document.createElement('div');
+ propertiesSection.className = 'properties-section';
+
+ const propertiesTitle = document.createElement('div');
+ propertiesTitle.className = 'properties-title';
+ propertiesTitle.textContent = 'Properties:';
+ propertiesSection.appendChild(propertiesTitle);
+
+ const requiredFields = schemaDef.required || [];
+
+ Object.entries(schemaDef.properties).forEach(([propName, propDef]) => {
+ const propertyDiv = createPropertyElement(propName, propDef, requiredFields);
+ propertiesSection.appendChild(propertyDiv);
+ });
+
+ contentDiv.appendChild(propertiesSection);
+ }
+
+ schemaDiv.appendChild(contentDiv);
+ return schemaDiv;
+ }
+
+ function createPropertyElement(propName, propDef, requiredFields) {
+ const propertyDiv = document.createElement('div');
+ propertyDiv.className = 'property';
+
+ // Property name
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'property-name';
+ nameSpan.textContent = propName;
+ propertyDiv.appendChild(nameSpan);
+
+ // Check if field is required
+ const isRequired = requiredFields.includes(propName);
+
+ // Check if field is nullable
+ const isNullable = isPropertyNullable(propDef);
+
+ // Add required/optional badge
+ const badge = document.createElement('span');
+ badge.className = isRequired ? 'property-required' : 'property-optional';
+ badge.textContent = isRequired ? 'REQUIRED' : 'OPTIONAL';
+ propertyDiv.appendChild(badge);
+
+ // Add nullable badge if applicable
+ if (isNullable) {
+ const nullableBadge = document.createElement('span');
+ nullableBadge.className = 'property-nullable';
+ nullableBadge.textContent = 'NULLABLE';
+ propertyDiv.appendChild(nullableBadge);
+ }
+
+ // Add type
+ const typeSpan = document.createElement('span');
+ typeSpan.className = 'property-type';
+ typeSpan.innerHTML = formatPropertyType(propDef);
+ propertyDiv.appendChild(typeSpan);
+
+ // Add description
+ if (propDef.description) {
+ const descDiv = document.createElement('div');
+ descDiv.className = 'property-description';
+ descDiv.textContent = propDef.description;
+ propertyDiv.appendChild(descDiv);
+ }
+
+ // Add enum values if present
+ if (propDef.enum) {
+ const enumDiv = document.createElement('div');
+ enumDiv.className = 'enum-values';
+
+ const enumTitle = document.createElement('div');
+ enumTitle.className = 'enum-values-title';
+ enumTitle.textContent = 'Possible values:';
+ enumDiv.appendChild(enumTitle);
+
+ propDef.enum.forEach(enumVal => {
+ const enumSpan = document.createElement('span');
+ enumSpan.className = 'enum-value';
+ enumSpan.textContent = enumVal;
+ enumDiv.appendChild(enumSpan);
+ });
+
+ propertyDiv.appendChild(enumDiv);
+ }
+
+ return propertyDiv;
+ }
+
+ function isPropertyNullable(propDef) {
+ if (propDef.type === 'null') {
+ return true;
+ }
+ if (propDef.anyOf) {
+ return propDef.anyOf.some(item => item.type === 'null');
+ }
+ if (propDef.oneOf) {
+ return propDef.oneOf.some(item => item.type === 'null');
+ }
+ return false;
+ }
+
+ function formatPropertyType(propDef) {
+ // Handle simple types
+ if (propDef.type && propDef.type !== 'null') {
+ if (propDef.type === 'array' && propDef.items) {
+ const itemType = formatPropertyType(propDef.items);
+ return `<span class="array-type">array of ${itemType}</span>`;
+ }
+ return propDef.type;
+ }
+
+ // Handle $ref
+ if (propDef.$ref) {
+ const refType = propDef.$ref.split('/').pop();
+ return `<a href="#schema-${refType}" class="type-link">${refType}</a>`;
+ }
+
+ // Handle anyOf/oneOf
+ if (propDef.anyOf) {
+ const types = propDef.anyOf
+ .filter(item => item.type !== 'null')
+ .map(item => formatPropertyType(item));
+ return types.join(' | ');
+ }
+
+ if (propDef.oneOf) {
+ const types = propDef.oneOf
+ .filter(item => item.type !== 'null')
+ .map(item => formatPropertyType(item));
+ return types.join(' | ');
+ }
+
+ return 'any';
+ }
+
+ // Toggle schema details
+ function toggleSchema(header) {
+ const schema = header.parentElement;
+ const content = schema.querySelector('.schema-content');
+ const icon = header.querySelector('.schema-expand-icon');
+
+ content.classList.toggle('show');
+ icon.classList.toggle('expanded');
+ }
+
+ // Handle deep linking - expand and scroll to schema
+ function handleDeepLinking() {
+ const hash = window.location.hash;
+ if (hash && hash.startsWith('#schema-')) {
+ scrollToSchema(hash);
+ }
+ }
+
+ function scrollToSchema(hash) {
+ const schemaElement = document.querySelector(hash);
+ if (schemaElement) {
+ // Expand the schema
+ const content = schemaElement.querySelector('.schema-content');
+ const icon = schemaElement.querySelector('.schema-expand-icon');
+ if (content && icon) {
+ content.classList.add('show');
+ icon.classList.add('expanded');
+ }
+ // Scroll to it
+ setTimeout(() => {
+ schemaElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ // Highlight temporarily
+ schemaElement.style.transition = 'opacity 0.3s';
+ const originalOpacity = schemaElement.style.opacity || '1';
+ schemaElement.style.opacity = '0.5';
+ setTimeout(() => {
+ schemaElement.style.opacity = originalOpacity;
+ }, 300);
+ setTimeout(() => {
+ schemaElement.style.opacity = originalOpacity;
+ }, 600);
+ }, 100);
+ }
+ }
+
+ // Listen for hash changes (when user clicks a type link)
+ window.addEventListener('hashchange', function() {
+ const hash = window.location.hash;
+ if (hash && hash.startsWith('#schema-')) {
+ scrollToSchema(hash);
+ }
+ });
+
+ // Search functionality
+ document.getElementById('search').addEventListener('input', function(e) {
+ const searchTerm = e.target.value.toLowerCase();
+ const schemas = document.querySelectorAll('.schema');
+
+ schemas.forEach(schema => {
+ const schemaName = schema.dataset.schema;
+ const schemaText = schema.textContent.toLowerCase();
+ const nameMatch = schemaName.toLowerCase().includes(searchTerm);
+ const textMatch = schemaText.includes(searchTerm);
+
+ if (nameMatch || textMatch) {
+ schema.classList.remove('hidden');
+ // Expand if search term is present and not just in the name
+ if (searchTerm && textMatch) {
+ const content = schema.querySelector('.schema-content');
+ const icon = schema.querySelector('.schema-expand-icon');
+ if (content && icon && !content.classList.contains('show')) {
+ content.classList.add('show');
+ icon.classList.add('expanded');
+ }
+ }
+ } else {
+ schema.classList.add('hidden');
+ }
+ });
+ });
+
+ // Load schemas on page load
+ loadSchemas();
+ </script>
+</body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Music Assistant - Setup</title>
+ <link rel="stylesheet" href="resources/common.css">
+ <style>
+ body {
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ }
+
+ .setup-container {
+ background: var(--panel);
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12),
+ 0 0 0 1px var(--border);
+ padding: 48px 40px;
+ width: 100%;
+ max-width: 520px;
+ }
+
+ .logo {
+ text-align: center;
+ margin-bottom: 36px;
+ }
+
+ .logo-icon {
+ width: 72px;
+ height: 72px;
+ margin: 0 auto 16px;
+ }
+
+ .logo-icon img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ .logo h1 {
+ color: var(--fg);
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.5px;
+ margin-bottom: 6px;
+ }
+
+ .logo p {
+ color: var(--text-tertiary);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ /* Step indicator */
+ .steps-indicator {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 32px;
+ gap: 12px;
+ }
+
+ .step-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--border);
+ transition: all 0.3s ease;
+ }
+
+ .step-dot.active {
+ background: var(--primary);
+ width: 32px;
+ border-radius: 5px;
+ }
+
+ .step-dot.completed {
+ background: var(--primary);
+ opacity: 0.5;
+ }
+
+ /* Step content */
+ .step {
+ display: none;
+ }
+
+ .step.active {
+ display: block;
+ animation: fadeIn 0.3s ease;
+ }
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .step-header {
+ margin-bottom: 24px;
+ }
+
+ .step-header h2 {
+ color: var(--fg);
+ font-size: 20px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ .step-header p {
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.6;
+ }
+
+ .info-box {
+ background: var(--input-focus-bg);
+ border-left: 3px solid var(--primary);
+ padding: 16px 18px;
+ margin-bottom: 24px;
+ border-radius: 0 8px 8px 0;
+ }
+
+ .info-box.ingress {
+ border-left-color: #4CAF50;
+ }
+
+ .info-box h3 {
+ color: var(--primary);
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ }
+
+ .info-box.ingress h3 {
+ color: #4CAF50;
+ }
+
+ .info-box p {
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+ margin: 0;
+ }
+
+ .info-box p + p {
+ margin-top: 8px;
+ }
+
+ .info-box ul {
+ margin: 8px 0 0 0;
+ padding-left: 20px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.8;
+ }
+
+ .password-requirements {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+
+ /* Buttons */
+ .step-actions {
+ margin-top: 24px;
+ display: flex;
+ gap: 12px;
+ }
+
+ .btn {
+ flex: 1;
+ padding: 15px;
+ border: none;
+ border-radius: 10px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ letter-spacing: 0.3px;
+ }
+
+ .btn-primary {
+ background: var(--primary);
+ color: white;
+ }
+
+ .btn-primary:hover {
+ filter: brightness(1.1);
+ box-shadow: 0 8px 24px var(--primary-glow);
+ transform: translateY(-1px);
+ }
+
+ .btn-primary:active {
+ transform: translateY(0);
+ filter: brightness(0.95);
+ }
+
+ .btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+ filter: none;
+ }
+
+ .btn-secondary {
+ background: var(--panel-secondary);
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+ }
+
+ .btn-secondary:hover {
+ background: var(--input-bg);
+ color: var(--fg);
+ }
+
+ /* Completion step */
+ .completion-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 20px;
+ background: var(--primary);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32px;
+ }
+
+ .completion-message {
+ text-align: center;
+ }
+
+ .completion-message h2 {
+ color: var(--fg);
+ font-size: 22px;
+ font-weight: 600;
+ margin-bottom: 12px;
+ }
+
+ .completion-message p {
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.6;
+ margin-bottom: 12px;
+ }
+
+ /* Loading state */
+ .loading {
+ display: none;
+ text-align: center;
+ padding: 30px 20px;
+ }
+
+ .loading.show {
+ display: block;
+ }
+
+ .spinner {
+ border: 2px solid var(--border);
+ border-top: 2px solid var(--primary);
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ animation: spin 0.8s linear infinite;
+ margin: 0 auto;
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+
+ .loading p {
+ margin-top: 16px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+ </style>
+</head>
+<body>
+ <div class="setup-container">
+ <div class="logo">
+ <div class="logo-icon">
+ <img src="logo.png" alt="Music Assistant">
+ </div>
+ <h1>Music Assistant</h1>
+ <p id="logoSubtitle">Setup Wizard</p>
+ </div>
+
+ <!-- Step indicator -->
+ <div class="steps-indicator">
+ <div class="step-dot active" data-step="0"></div>
+ <div class="step-dot" data-step="1"></div>
+ <div class="step-dot" data-step="2"></div>
+ </div>
+
+ <!-- Step 0: Welcome -->
+ <div class="step active" data-step="0">
+ <div class="step-header">
+ <h2>Welcome to Music Assistant!</h2>
+ <p>Let's get you started with your personal music server. This setup wizard will guide you through the initial configuration.</p>
+ </div>
+
+ <div class="info-box">
+ <h3>What you'll set up:</h3>
+ <p><strong>Step 1:</strong> Create your administrator account</p>
+ <p><strong>Step 2:</strong> Complete the setup process</p>
+ </div>
+
+ <div class="step-actions">
+ <button type="button" class="btn btn-primary" onclick="nextStep()">Get Started</button>
+ </div>
+ </div>
+
+ <!-- Step 1: Create Admin Account -->
+ <div class="step" data-step="1">
+ <div class="step-header">
+ <h2>Create Administrator Account</h2>
+ <p>Your admin credentials will be used to access the Music Assistant web interface and mobile apps.</p>
+ </div>
+
+ <div class="info-box ingress" id="ingressAccountInfo" style="display: none;">
+ <h3>About your login credentials</h3>
+ <p>While Home Assistant handles authentication for Ingress access, you'll need these credentials for:</p>
+ <ul>
+ <li>Direct access to Music Assistant (outside Home Assistant)</li>
+ <li>Music Assistant mobile apps</li>
+ <li>API access and third-party integrations</li>
+ </ul>
+ </div>
+
+ <div class="error-message" id="errorMessage"></div>
+
+ <form id="setupForm">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input
+ type="text"
+ id="username"
+ name="username"
+ required
+ autocomplete="username"
+ placeholder="Enter your username"
+ minlength="3"
+ >
+ </div>
+
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ required
+ autocomplete="new-password"
+ placeholder="Enter a secure password"
+ minlength="8"
+ >
+ <div class="password-requirements">
+ Minimum 8 characters recommended
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="confirmPassword">Confirm Password</label>
+ <input
+ type="password"
+ id="confirmPassword"
+ name="confirmPassword"
+ required
+ autocomplete="new-password"
+ placeholder="Re-enter your password"
+ >
+ </div>
+
+ <div class="step-actions">
+ <button type="button" class="btn btn-secondary" onclick="previousStep()">Back</button>
+ <button type="submit" class="btn btn-primary" id="createAccountBtn">Create Account</button>
+ </div>
+ </form>
+
+ <div class="loading" id="loading">
+ <div class="spinner"></div>
+ <p>Creating your account...</p>
+ </div>
+ </div>
+
+ <!-- Step 2: Completion -->
+ <div class="step" data-step="2">
+ <div class="completion-icon">
+ ✓
+ </div>
+ <div class="completion-message">
+ <h2>Setup Complete!</h2>
+ <p>Your Music Assistant server has been successfully configured and is ready to use.</p>
+ <p>You can now start adding music providers and connecting your speakers to begin enjoying your music library.</p>
+ </div>
+
+ <div class="step-actions">
+ <button type="button" class="btn btn-primary" onclick="completeSetup()">Continue to Music Assistant</button>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ // Get query parameters
+ const urlParams = new URLSearchParams(window.location.search);
+ const deviceName = urlParams.get('device_name');
+ const returnUrl = urlParams.get('return_url');
+
+ let currentStep = 0;
+ let authToken = null;
+
+ // Validate URL to prevent XSS attacks
+ // Note: Server-side validation is the primary security layer
+ function isValidRedirectUrl(url) {
+ if (!url) return false;
+ try {
+ const parsed = new URL(url, window.location.origin);
+ // Allow http, https, and custom mobile app schemes
+ const allowedProtocols = ['http:', 'https:', 'musicassistant:'];
+ return allowedProtocols.includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+ }
+
+ // Check if this is an Ingress setup (variables injected by server)
+ const isIngressSetup = typeof ingressUsername !== 'undefined' && ingressUsername;
+
+ // Initialize UI
+ if (isIngressSetup) {
+ // Show ingress-specific information
+ document.getElementById('ingressAccountInfo').style.display = 'block';
+
+ // Pre-fill and disable username field (provided by Home Assistant)
+ const usernameField = document.getElementById('username');
+ usernameField.value = ingressUsername;
+ usernameField.disabled = true;
+ }
+
+ function updateStepIndicator() {
+ document.querySelectorAll('.step-dot').forEach((dot, index) => {
+ dot.classList.remove('active', 'completed');
+ if (index === currentStep) {
+ dot.classList.add('active');
+ } else if (index < currentStep) {
+ dot.classList.add('completed');
+ }
+ });
+ }
+
+ function showStep(stepNumber) {
+ document.querySelectorAll('.step').forEach(step => {
+ step.classList.remove('active');
+ });
+ document.querySelector(`.step[data-step="${stepNumber}"]`).classList.add('active');
+ currentStep = stepNumber;
+ updateStepIndicator();
+
+ // Update logo subtitle
+ const subtitles = ['Setup Wizard', 'Create Account', 'All Set!'];
+ document.getElementById('logoSubtitle').textContent = subtitles[stepNumber];
+ }
+
+ function nextStep() {
+ if (currentStep < 2) {
+ showStep(currentStep + 1);
+ }
+ }
+
+ function previousStep() {
+ if (currentStep > 0) {
+ showStep(currentStep - 1);
+ }
+ }
+
+ function showError(message) {
+ const errorMessage = document.getElementById('errorMessage');
+ errorMessage.textContent = message;
+ errorMessage.classList.add('show');
+ }
+
+ function hideError() {
+ const errorMessage = document.getElementById('errorMessage');
+ errorMessage.classList.remove('show');
+ }
+
+ function setLoading(isLoading) {
+ const form = document.getElementById('setupForm');
+ const loading = document.getElementById('loading');
+
+ if (isLoading) {
+ form.style.display = 'none';
+ loading.classList.add('show');
+ } else {
+ form.style.display = 'block';
+ loading.classList.remove('show');
+ }
+ }
+
+ // Handle form submission
+ document.getElementById('setupForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ hideError();
+
+ const username = document.getElementById('username').value.trim();
+ const password = document.getElementById('password').value;
+ const confirmPassword = document.getElementById('confirmPassword').value;
+
+ // Validation
+ if (username.length < 3) {
+ showError('Username must be at least 3 characters long');
+ return;
+ }
+
+ if (password.length < 8) {
+ showError('Password must be at least 8 characters long');
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ showError('Passwords do not match');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const requestBody = {
+ username: username,
+ password: password,
+ };
+
+ // Include device_name if provided via query parameter
+ if (deviceName) {
+ requestBody.device_name = deviceName;
+ }
+
+ // Include Ingress context if applicable
+ if (isIngressSetup) {
+ requestBody.from_ingress = true;
+ requestBody.display_name = ingressDisplayName;
+ }
+
+ const response = await fetch('setup', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ // Store token for later
+ authToken = data.token;
+
+ // Move to completion step
+ setLoading(false);
+ showStep(2);
+ } else {
+ setLoading(false);
+ showError(data.error || 'Setup failed. Please try again.');
+ }
+ } catch (error) {
+ setLoading(false);
+ showError('Network error. Please check your connection and try again.');
+ console.error('Setup error:', error);
+ }
+ });
+
+ function completeSetup() {
+ if (returnUrl && isValidRedirectUrl(returnUrl)) {
+ // Insert code and onboard parameters before any hash fragment
+ let finalUrl = returnUrl;
+
+ if (returnUrl.includes('#')) {
+ // Split URL by hash
+ const parts = returnUrl.split('#', 2);
+ const basePart = parts[0];
+ const hashPart = parts[1];
+ const separator = basePart.includes('?') ? '&' : '?';
+ finalUrl = `${basePart}${separator}code=${encodeURIComponent(authToken)}&onboard=true#${hashPart}`;
+ } else {
+ const separator = returnUrl.includes('?') ? '&' : '?';
+ finalUrl = `${returnUrl}${separator}code=${encodeURIComponent(authToken)}&onboard=true`;
+ }
+
+ window.location.href = finalUrl;
+ } else {
+ // No return URL - redirect to root with onboard flag
+ // Frontend will handle navigation to onboarding flow
+ window.location.href = '/?code=' + encodeURIComponent(authToken) + '&onboard=true';
+ }
+ }
+
+ // Clear error on input
+ document.querySelectorAll('input').forEach(input => {
+ input.addEventListener('input', hideError);
+ });
+ </script>
+</body>
+</html>
margin: 0;
padding: 0;
}
- .topbar {
- display: none;
- }
- .swagger-ui .info {
- margin: 30px 0;
- }
- .swagger-ui .info .title {
- font-size: 2.5em;
- }
</style>
</head>
<body>
<div id="swagger-ui"></div>
+
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"></script>
<script>
static_routes: list[tuple[str, str, Handler]] | None = None,
static_content: tuple[str, str, str] | None = None,
ingress_tcp_site_params: tuple[str, int] | None = None,
+ app_state: dict[str, Any] | None = None,
) -> None:
- """Async initialize of module."""
+ """Async initialize of module.
+
+ :param bind_ip: IP address to bind to.
+ :param bind_port: Port to bind to.
+ :param base_url: Base URL for the server.
+ :param static_routes: List of static routes to register.
+ :param static_content: Tuple of (path, directory, name) for static content.
+ :param ingress_tcp_site_params: Tuple of (host, port) for ingress TCP site.
+ :param app_state: Optional dict of key-value pairs to set on app before starting.
+ """
self._base_url = base_url.removesuffix("/")
self._bind_port = bind_port
self._static_routes = static_routes
"max_field_size": MAX_LINE_SIZE,
},
)
+ # Set app state before starting
+ if app_state:
+ for key, value in app_state.items():
+ self._webapp[key] = value
self._apprunner = web.AppRunner(self._webapp, access_log=None, shutdown_timeout=10)
# add static routes
if self._static_routes:
# this is only used if we're running in the context of an HA add-on
# which proxies our frontend and api through ingress
if ingress_tcp_site_params:
+ # Store ingress site reference in app for security checks
+ self._webapp["ingress_site"] = ingress_tcp_site_params
self._ingress_tcp_site = web.TCPSite(
self._apprunner,
host=ingress_tcp_site_params[0],
self,
command: str,
handler: Callable[..., Coroutine[Any, Any, Any] | AsyncGenerator[Any, Any]],
+ authenticated: bool = True,
+ required_role: str | None = None,
+ alias: bool = False,
) -> Callable[[], None]:
- """
- Dynamically register a command on the API.
+ """Dynamically register a command on the API.
+
+ :param command: The command name/path.
+ :param handler: The function to handle the command.
+ :param authenticated: Whether authentication is required (default: True).
+ :param required_role: Required user role ("admin" or "user")
+ None means any authenticated user.
+ :param alias: Whether this is an alias for backward compatibility (default: False).
+ Aliases are not shown in API documentation but remain functional.
Returns handle to unregister.
"""
if command in self.command_handlers:
msg = f"Command {command} is already registered"
raise RuntimeError(msg)
- self.command_handlers[command] = APICommandHandler.parse(command, handler)
+ self.command_handlers[command] = APICommandHandler.parse(
+ command, handler, authenticated, required_role, alias
+ )
def unregister() -> None:
self.command_handlers.pop(command)
self.music,
self.players,
self.player_queues,
+ self.webserver,
+ self.webserver.auth,
):
for attr_name in dir(cls):
if attr_name.startswith("__"):
continue
- obj = getattr(cls, attr_name)
+ try:
+ obj = getattr(cls, attr_name)
+ except (AttributeError, RuntimeError):
+ # Skip properties that fail during initialization
+ continue
if hasattr(obj, "api_cmd"):
# method is decorated with our api decorator
- self.register_api_command(obj.api_cmd, obj)
+ authenticated = getattr(obj, "api_authenticated", True)
+ required_role = getattr(obj, "api_required_role", None)
+ self.register_api_command(obj.api_cmd, obj, authenticated, required_role)
async def _load_providers(self) -> None:
"""Load providers from config."""
--- /dev/null
+"""Tests for webserver authentication and user management."""
+
+import asyncio
+import hashlib
+import logging
+import pathlib
+from collections.abc import AsyncGenerator
+from datetime import timedelta
+
+import pytest
+from music_assistant_models.auth import AuthProviderType, UserRole
+from music_assistant_models.errors import InvalidDataError
+
+from music_assistant.constants import HOMEASSISTANT_SYSTEM_USER
+from music_assistant.controllers.config import ConfigController
+from music_assistant.controllers.webserver.auth import AuthenticationManager
+from music_assistant.controllers.webserver.controller import WebserverController
+from music_assistant.controllers.webserver.helpers.auth_middleware import (
+ set_current_token,
+ set_current_user,
+)
+from music_assistant.controllers.webserver.helpers.auth_providers import BuiltinLoginProvider
+from music_assistant.helpers.datetime import utc
+from music_assistant.mass import MusicAssistant
+
+
+@pytest.fixture
+async def mass_minimal(tmp_path: pathlib.Path) -> AsyncGenerator[MusicAssistant, None]:
+ """Create a minimal Music Assistant instance for auth testing without starting the webserver.
+
+ :param tmp_path: Temporary directory for test data.
+ """
+ storage_path = tmp_path / "data"
+ cache_path = tmp_path / "cache"
+ storage_path.mkdir(parents=True)
+ cache_path.mkdir(parents=True)
+
+ # Suppress aiosqlite debug logging
+ logging.getLogger("aiosqlite").level = logging.INFO
+
+ mass_instance = MusicAssistant(str(storage_path), str(cache_path))
+
+ # Initialize the minimum required for auth testing
+ mass_instance.loop = asyncio.get_running_loop()
+ # Use id() as fallback since _thread_id is a private attribute that may not exist
+ mass_instance.loop_thread_id = (
+ getattr(mass_instance.loop, "_thread_id", None)
+ if hasattr(mass_instance.loop, "_thread_id")
+ else id(mass_instance.loop)
+ )
+
+ # Create config controller
+ mass_instance.config = ConfigController(mass_instance)
+ await mass_instance.config.setup()
+
+ # Create webserver controller (but don't start the actual server)
+ webserver = WebserverController(mass_instance)
+ mass_instance.webserver = webserver
+
+ # Get webserver config and manually set it (avoids starting the server)
+ webserver_config = await mass_instance.config.get_core_config("webserver")
+ webserver.config = webserver_config
+
+ # Setup auth manager only (not the full webserver with routes/sockets)
+ await webserver.auth.setup()
+
+ try:
+ yield mass_instance
+ finally:
+ # Cleanup
+ await webserver.auth.close()
+ await mass_instance.config.close()
+
+
+@pytest.fixture
+async def auth_manager(mass_minimal: MusicAssistant) -> AuthenticationManager:
+ """Get authentication manager from mass instance.
+
+ :param mass_minimal: Minimal MusicAssistant instance.
+ """
+ return mass_minimal.webserver.auth
+
+
+async def test_auth_manager_initialization(auth_manager: AuthenticationManager) -> None:
+ """Test that the authentication manager initializes correctly.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ assert auth_manager is not None
+ assert auth_manager.database is not None
+ assert "builtin" in auth_manager.login_providers
+ assert isinstance(auth_manager.login_providers["builtin"], BuiltinLoginProvider)
+
+
+async def test_has_users_initially_empty(auth_manager: AuthenticationManager) -> None:
+ """Test that has_users returns False when no users exist.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ has_users = await auth_manager.has_users()
+ assert has_users is False
+
+
+async def test_create_user(auth_manager: AuthenticationManager) -> None:
+ """Test creating a new user.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(
+ username="testuser",
+ role=UserRole.USER,
+ display_name="Test User",
+ )
+
+ assert user is not None
+ assert user.username == "testuser"
+ assert user.role == UserRole.USER
+ assert user.display_name == "Test User"
+ assert user.enabled is True
+ assert user.user_id is not None
+
+ # Verify user exists in database
+ has_users = await auth_manager.has_users()
+ assert has_users is True
+
+
+async def test_get_user(auth_manager: AuthenticationManager) -> None:
+ """Test retrieving a user by ID.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Create a user first
+ created_user = await auth_manager.create_user(username="getuser", role=UserRole.USER)
+
+ # Set current user for authorization (get_user requires admin role)
+ admin_user = await auth_manager.create_user(username="admin", role=UserRole.ADMIN)
+ set_current_user(admin_user)
+
+ # Retrieve the user
+ retrieved_user = await auth_manager.get_user(created_user.user_id)
+
+ assert retrieved_user is not None
+ assert retrieved_user.user_id == created_user.user_id
+ assert retrieved_user.username == created_user.username
+
+
+async def test_create_user_with_builtin_provider(auth_manager: AuthenticationManager) -> None:
+ """Test creating a user with built-in authentication.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ builtin_provider = auth_manager.login_providers.get("builtin")
+ assert builtin_provider is not None
+ assert isinstance(builtin_provider, BuiltinLoginProvider)
+
+ user = await builtin_provider.create_user_with_password(
+ username="testuser2",
+ password="testpassword123",
+ role=UserRole.USER,
+ )
+
+ assert user is not None
+ assert user.username == "testuser2"
+
+
+async def test_authenticate_with_password(auth_manager: AuthenticationManager) -> None:
+ """Test authenticating with username and password.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ builtin_provider = auth_manager.login_providers.get("builtin")
+ assert builtin_provider is not None
+ assert isinstance(builtin_provider, BuiltinLoginProvider)
+
+ # Create user with password
+ await builtin_provider.create_user_with_password(
+ username="authtest",
+ password="secure_password_123",
+ role=UserRole.USER,
+ )
+
+ # Test successful authentication
+ result = await auth_manager.authenticate_with_credentials(
+ "builtin",
+ {"username": "authtest", "password": "secure_password_123"},
+ )
+
+ assert result.success is True
+ assert result.user is not None
+ assert result.user.username == "authtest"
+ # Note: Built-in provider doesn't auto-generate access token on login,
+ # that's done by the web login flow. We just verify authentication succeeds.
+
+ # Test failed authentication with wrong password
+ result = await auth_manager.authenticate_with_credentials(
+ "builtin",
+ {"username": "authtest", "password": "wrong_password"},
+ )
+
+ assert result.success is False
+ assert result.user is None
+ assert result.error is not None
+
+
+async def test_create_token(auth_manager: AuthenticationManager) -> None:
+ """Test creating access tokens.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="tokenuser", role=UserRole.USER)
+
+ # Create short-lived token
+ short_token = await auth_manager.create_token(user, "Test Device", is_long_lived=False)
+ assert short_token is not None
+ assert len(short_token) > 0
+
+ # Create long-lived token
+ long_token = await auth_manager.create_token(user, "API Key", is_long_lived=True)
+ assert long_token is not None
+ assert len(long_token) > 0
+ assert long_token != short_token
+
+
+async def test_authenticate_with_token(auth_manager: AuthenticationManager) -> None:
+ """Test authenticating with an access token.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="tokenauth", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Test Token", is_long_lived=False)
+
+ # Authenticate with token
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+
+ assert authenticated_user is not None
+ assert authenticated_user.user_id == user.user_id
+ assert authenticated_user.username == user.username
+
+
+async def test_token_expiration(auth_manager: AuthenticationManager) -> None:
+ """Test that expired tokens are rejected.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="expireuser", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Expire Test", is_long_lived=False)
+
+ # Hash the token to look it up
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_row = await auth_manager.database.get_row("auth_tokens", {"token_hash": token_hash})
+ assert token_row is not None
+
+ # Manually expire the token by setting expires_at in the past
+ past_time = utc() - timedelta(days=1)
+ await auth_manager.database.update(
+ "auth_tokens",
+ {"token_id": token_row["token_id"]},
+ {"expires_at": past_time.isoformat()},
+ )
+
+ # Try to authenticate with expired token
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is None
+
+
+async def test_update_user_profile(auth_manager: AuthenticationManager) -> None:
+ """Test updating user profile information.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(
+ username="updateuser",
+ role=UserRole.USER,
+ display_name="Original Name",
+ )
+
+ # Update user profile
+ updated_user = await auth_manager.update_user(
+ user,
+ display_name="New Name",
+ avatar_url="https://example.com/avatar.jpg",
+ )
+
+ assert updated_user is not None
+ assert updated_user.display_name == "New Name"
+ assert updated_user.avatar_url == "https://example.com/avatar.jpg"
+ assert updated_user.username == user.username
+
+
+async def test_change_password(auth_manager: AuthenticationManager) -> None:
+ """Test changing user password.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ builtin_provider = auth_manager.login_providers.get("builtin")
+ assert builtin_provider is not None
+ assert isinstance(builtin_provider, BuiltinLoginProvider)
+
+ # Create user with password
+ user = await builtin_provider.create_user_with_password(
+ username="pwdchange",
+ password="old_password_123",
+ role=UserRole.USER,
+ )
+
+ # Change password
+ success = await builtin_provider.change_password(
+ user,
+ "old_password_123",
+ "new_password_456",
+ )
+ assert success is True
+
+ # Verify old password no longer works
+ result = await auth_manager.authenticate_with_credentials(
+ "builtin",
+ {"username": "pwdchange", "password": "old_password_123"},
+ )
+ assert result.success is False
+
+ # Verify new password works
+ result = await auth_manager.authenticate_with_credentials(
+ "builtin",
+ {"username": "pwdchange", "password": "new_password_456"},
+ )
+ assert result.success is True
+
+
+async def test_revoke_token(auth_manager: AuthenticationManager) -> None:
+ """Test revoking an access token.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="revokeuser", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Revoke Test", is_long_lived=False)
+
+ # Set current user context for authorization
+ set_current_user(user)
+
+ # Get token_id
+ token_id = await auth_manager.get_token_id_from_token(token)
+ assert token_id is not None
+
+ # Token should work before revocation
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is not None
+
+ # Revoke the token
+ await auth_manager.revoke_token(token_id)
+
+ # Token should not work after revocation
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is None
+
+
+async def test_list_users(auth_manager: AuthenticationManager) -> None:
+ """Test listing all users (admin only).
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Create admin user and set as current
+ admin = await auth_manager.create_user(username="listadmin", role=UserRole.ADMIN)
+ set_current_user(admin)
+
+ # Create some test users
+ await auth_manager.create_user(username="user1", role=UserRole.USER)
+ await auth_manager.create_user(username="user2", role=UserRole.USER)
+
+ # List all users
+ users = await auth_manager.list_users()
+
+ # Should not include system users
+ usernames = [u.username for u in users]
+ assert "listadmin" in usernames
+ assert "user1" in usernames
+ assert "user2" in usernames
+
+
+async def test_disable_enable_user(auth_manager: AuthenticationManager) -> None:
+ """Test disabling and enabling user accounts.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Create admin and regular user
+ admin = await auth_manager.create_user(username="disableadmin", role=UserRole.ADMIN)
+ user = await auth_manager.create_user(username="disableuser", role=UserRole.USER)
+
+ # Set admin as current user
+ set_current_user(admin)
+
+ # Disable the user
+ await auth_manager.disable_user(user.user_id)
+
+ # Verify user is disabled
+ disabled_user = await auth_manager.get_user(user.user_id)
+ assert disabled_user is None # get_user filters out disabled users
+
+ # Enable the user
+ await auth_manager.enable_user(user.user_id)
+
+ # Verify user is enabled
+ enabled_user = await auth_manager.get_user(user.user_id)
+ assert enabled_user is not None
+
+
+async def test_cannot_disable_own_account(auth_manager: AuthenticationManager) -> None:
+ """Test that users cannot disable their own account.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ admin = await auth_manager.create_user(username="selfadmin", role=UserRole.ADMIN)
+ set_current_user(admin)
+
+ # Try to disable own account
+ with pytest.raises(InvalidDataError, match="Cannot disable your own account"):
+ await auth_manager.disable_user(admin.user_id)
+
+
+async def test_user_preferences(auth_manager: AuthenticationManager) -> None:
+ """Test updating user preferences.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="prefuser", role=UserRole.USER)
+
+ # Update preferences
+ preferences = {"theme": "dark", "language": "en"}
+ updated_user = await auth_manager.update_user_preferences(user, preferences)
+
+ assert updated_user is not None
+ assert updated_user.preferences == preferences
+
+
+async def test_link_user_to_provider(auth_manager: AuthenticationManager) -> None:
+ """Test linking user to authentication provider.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="linkuser", role=UserRole.USER)
+
+ # Link to provider
+ link = await auth_manager.link_user_to_provider(
+ user,
+ AuthProviderType.HOME_ASSISTANT,
+ "ha_user_123",
+ )
+
+ assert link is not None
+ assert link.user_id == user.user_id
+ assert link.provider_type == AuthProviderType.HOME_ASSISTANT
+ assert link.provider_user_id == "ha_user_123"
+
+ # Retrieve user by provider link
+ retrieved_user = await auth_manager.get_user_by_provider_link(
+ AuthProviderType.HOME_ASSISTANT,
+ "ha_user_123",
+ )
+
+ assert retrieved_user is not None
+ assert retrieved_user.user_id == user.user_id
+
+
+async def test_homeassistant_system_user(auth_manager: AuthenticationManager) -> None:
+ """Test Home Assistant system user creation.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Get or create system user
+ system_user = await auth_manager.get_homeassistant_system_user()
+
+ assert system_user is not None
+ assert system_user.username == HOMEASSISTANT_SYSTEM_USER
+ assert system_user.display_name == "Home Assistant Integration"
+ assert system_user.role == UserRole.USER
+
+ # Getting it again should return the same user
+ system_user2 = await auth_manager.get_homeassistant_system_user()
+ assert system_user2.user_id == system_user.user_id
+
+
+async def test_homeassistant_system_user_token(auth_manager: AuthenticationManager) -> None:
+ """Test Home Assistant system user token creation.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Get or create token
+ token1 = await auth_manager.get_homeassistant_system_user_token()
+ assert token1 is not None
+
+ # Getting it again should create a new token (old one is replaced)
+ token2 = await auth_manager.get_homeassistant_system_user_token()
+ assert token2 is not None
+ assert token2 != token1
+
+ # Old token should not work
+ user1 = await auth_manager.authenticate_with_token(token1)
+ assert user1 is None
+
+ # New token should work
+ user2 = await auth_manager.authenticate_with_token(token2)
+ assert user2 is not None
+
+
+async def test_update_user_role(auth_manager: AuthenticationManager) -> None:
+ """Test updating user role (admin only).
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ admin = await auth_manager.create_user(username="roleadmin", role=UserRole.ADMIN)
+ user = await auth_manager.create_user(username="roleuser", role=UserRole.USER)
+
+ # Update role
+ success = await auth_manager.update_user_role(user.user_id, UserRole.ADMIN, admin)
+ assert success is True
+
+ # Verify role was updated
+ set_current_user(admin)
+ updated_user = await auth_manager.get_user(user.user_id)
+ assert updated_user is not None
+ assert updated_user.role == UserRole.ADMIN
+
+
+async def test_delete_user(auth_manager: AuthenticationManager) -> None:
+ """Test deleting a user account.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ admin = await auth_manager.create_user(username="deleteadmin", role=UserRole.ADMIN)
+ user = await auth_manager.create_user(username="deleteuser", role=UserRole.USER)
+
+ # Set admin as current user
+ set_current_user(admin)
+
+ # Delete the user
+ await auth_manager.delete_user(user.user_id)
+
+ # Verify user is deleted
+ deleted_user = await auth_manager.get_user(user.user_id)
+ assert deleted_user is None
+
+
+async def test_cannot_delete_own_account(auth_manager: AuthenticationManager) -> None:
+ """Test that users cannot delete their own account.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ admin = await auth_manager.create_user(username="selfdeleteadmin", role=UserRole.ADMIN)
+ set_current_user(admin)
+
+ # Try to delete own account
+ with pytest.raises(InvalidDataError, match="Cannot delete your own account"):
+ await auth_manager.delete_user(admin.user_id)
+
+
+async def test_get_user_tokens(auth_manager: AuthenticationManager) -> None:
+ """Test getting user's tokens.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="tokensuser", role=UserRole.USER)
+ set_current_user(user)
+
+ # Create some tokens
+ await auth_manager.create_token(user, "Device 1", is_long_lived=False)
+ await auth_manager.create_token(user, "Device 2", is_long_lived=True)
+
+ # Get user tokens
+ tokens = await auth_manager.get_user_tokens(user.user_id)
+
+ assert len(tokens) == 2
+ token_names = [t.name for t in tokens]
+ assert "Device 1" in token_names
+ assert "Device 2" in token_names
+
+
+async def test_get_login_providers(auth_manager: AuthenticationManager) -> None:
+ """Test getting available login providers.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ providers = await auth_manager.get_login_providers()
+
+ assert len(providers) > 0
+ assert any(p["provider_id"] == "builtin" for p in providers)
+
+
+async def test_create_user_with_api(auth_manager: AuthenticationManager) -> None:
+ """Test creating user via API command.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ # Create admin user and set as current
+ admin = await auth_manager.create_user(username="apiadmin", role=UserRole.ADMIN)
+ set_current_user(admin)
+
+ # Create user via API
+ user = await auth_manager.create_user_with_api(
+ username="apiuser",
+ password="password123",
+ role="user",
+ display_name="API User",
+ )
+
+ assert user is not None
+ assert user.username == "apiuser"
+ assert user.role == UserRole.USER
+ assert user.display_name == "API User"
+
+
+async def test_create_user_api_validation(auth_manager: AuthenticationManager) -> None:
+ """Test validation in create_user_with_api.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ admin = await auth_manager.create_user(username="validadmin", role=UserRole.ADMIN)
+ set_current_user(admin)
+
+ # Test username too short
+ with pytest.raises(InvalidDataError, match="Username must be at least 3 characters"):
+ await auth_manager.create_user_with_api(
+ username="ab",
+ password="password123",
+ )
+
+ # Test password too short
+ with pytest.raises(InvalidDataError, match="Password must be at least 8 characters"):
+ await auth_manager.create_user_with_api(
+ username="validuser",
+ password="short",
+ )
+
+
+async def test_logout(auth_manager: AuthenticationManager) -> None:
+ """Test logout functionality.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="logoutuser", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Logout Test", is_long_lived=False)
+
+ # Set current user and token
+ set_current_user(user)
+ set_current_token(token)
+
+ # Token should work before logout
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is not None
+
+ # Logout
+ await auth_manager.logout()
+
+ # Token should not work after logout
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is None
+
+
+async def test_token_sliding_expiration(auth_manager: AuthenticationManager) -> None:
+ """Test that short-lived tokens auto-renew on use.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="slideuser", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Slide Test", is_long_lived=False)
+
+ # Get initial expiration
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_row = await auth_manager.database.get_row("auth_tokens", {"token_hash": token_hash})
+ assert token_row is not None
+ initial_expires_at = token_row["expires_at"]
+
+ # Use the token (authenticate)
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is not None
+
+ # Check that expiration was updated
+ token_row = await auth_manager.database.get_row("auth_tokens", {"token_hash": token_hash})
+ assert token_row is not None
+ updated_expires_at = token_row["expires_at"]
+
+ # Expiration should have been extended
+ assert updated_expires_at != initial_expires_at
+
+
+async def test_long_lived_token_no_auto_renewal(auth_manager: AuthenticationManager) -> None:
+ """Test that long-lived tokens do NOT auto-renew on use.
+
+ :param auth_manager: AuthenticationManager instance.
+ """
+ user = await auth_manager.create_user(username="longuser", role=UserRole.USER)
+ token = await auth_manager.create_token(user, "Long Test", is_long_lived=True)
+
+ # Get initial expiration
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ token_row = await auth_manager.database.get_row("auth_tokens", {"token_hash": token_hash})
+ assert token_row is not None
+ initial_expires_at = token_row["expires_at"]
+
+ # Use the token (authenticate)
+ authenticated_user = await auth_manager.authenticate_with_token(token)
+ assert authenticated_user is not None
+
+ # Check that expiration was NOT updated
+ token_row = await auth_manager.database.get_row("auth_tokens", {"token_hash": token_hash})
+ assert token_row is not None
+ updated_expires_at = token_row["expires_at"]
+
+ # Expiration should remain the same for long-lived tokens
+ assert updated_expires_at == initial_expires_at