From ca7ca36bebcaab3e45a5ea9328e365e5e45bf62a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 1 Dec 2025 02:30:01 +0100 Subject: [PATCH] Move remote access into its own controller --- music_assistant/constants.py | 1 + .../controllers/remote_access/__init__.py | 5 + .../controller.py} | 119 +++++++++---- .../{webserver => }/remote_access/gateway.py | 159 ++++++++++++++---- .../controllers/webserver/controller.py | 26 ++- music_assistant/mass.py | 6 + 6 files changed, 241 insertions(+), 75 deletions(-) create mode 100644 music_assistant/controllers/remote_access/__init__.py rename music_assistant/controllers/{webserver/remote_access/__init__.py => remote_access/controller.py} (68%) rename music_assistant/controllers/{webserver => }/remote_access/gateway.py (79%) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 01b545fc..09b24eab 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -141,6 +141,7 @@ CONFIGURABLE_CORE_CONTROLLERS = ( "cache", "music", "player_queues", + "remote_access", ) VERBOSE_LOG_LEVEL: Final[int] = 5 PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz") diff --git a/music_assistant/controllers/remote_access/__init__.py b/music_assistant/controllers/remote_access/__init__.py new file mode 100644 index 00000000..16eb8066 --- /dev/null +++ b/music_assistant/controllers/remote_access/__init__.py @@ -0,0 +1,5 @@ +"""Remote Access controller for Music Assistant.""" + +from music_assistant.controllers.remote_access.controller import RemoteAccessController + +__all__ = ["RemoteAccessController"] diff --git a/music_assistant/controllers/webserver/remote_access/__init__.py b/music_assistant/controllers/remote_access/controller.py similarity index 68% rename from music_assistant/controllers/webserver/remote_access/__init__.py rename to music_assistant/controllers/remote_access/controller.py index 5948906e..c7f28913 100644 --- a/music_assistant/controllers/webserver/remote_access/__init__.py +++ b/music_assistant/controllers/remote_access/controller.py @@ -1,51 +1,106 @@ -"""Remote Access manager for Music Assistant webserver.""" +""" +Remote Access Controller for Music Assistant. + +This controller manages WebRTC-based remote access to Music Assistant instances. +It connects to a signaling server and handles incoming WebRTC connections, +bridging them to the local WebSocket API. +""" from __future__ import annotations -import logging from typing import TYPE_CHECKING -from music_assistant_models.enums import EventType +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType, EventType -from music_assistant.constants import MASS_LOGGER_NAME -from music_assistant.controllers.webserver.remote_access.gateway import ( - WebRTCGateway, - generate_remote_id, -) +from music_assistant.controllers.remote_access.gateway import WebRTCGateway, generate_remote_id from music_assistant.helpers.api import api_command +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.controllers.webserver import WebserverController - -LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.remote_access") + from music_assistant import MusicAssistant # Signaling server URL SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws" -# Internal config key for storing the remote ID -_CONF_REMOTE_ID = "remote_id" +# Config keys +CONF_REMOTE_ID = "remote_id" +CONF_ENABLED = "enabled" + +class RemoteAccessController(CoreController): + """Core Controller for WebRTC-based remote access.""" -class RemoteAccessManager: - """Manager for WebRTC-based remote access (part of webserver controller).""" + domain: str = "remote_access" - def __init__(self, webserver: WebserverController) -> None: - """Initialize the remote access manager. + def __init__(self, mass: MusicAssistant) -> None: + """Initialize the remote access controller. - :param webserver: WebserverController instance. + :param mass: MusicAssistant instance. """ - self.webserver = webserver - self.mass = webserver.mass - self.logger = LOGGER + super().__init__(mass) + self.manifest.name = "Remote Access" + self.manifest.description = ( + "WebRTC-based remote access for connecting to Music Assistant " + "from outside your local network (requires Home Assistant Cloud subscription)" + ) + self.manifest.icon = "cloud-lock" self.gateway: WebRTCGateway | None = None self._remote_id: str | None = None self._setup_done = False - async def setup(self) -> None: - """Initialize the remote access manager.""" - self.logger.debug("RemoteAccessManager.setup() called") + 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).""" + entries = [] + + # Info alert about HA Cloud requirement + entries.append( + ConfigEntry( + key="remote_access_info", + type=ConfigEntryType.ALERT, + label="Remote Access requires an active Home Assistant Cloud subscription. " + "Once detected, remote access will be automatically enabled and you will " + "receive a unique Remote ID for connecting from outside your network.", + required=False, + ) + ) + + entries.append( + ConfigEntry( + key=CONF_ENABLED, + type=ConfigEntryType.BOOLEAN, + default_value=True, + label="Enable Remote Access", + description="Enable WebRTC-based remote access when Home Assistant Cloud " + "subscription is detected. Disable this if you don't want to use remote access.", + ) + ) + + entries.append( + ConfigEntry( + key=CONF_REMOTE_ID, + type=ConfigEntryType.STRING, + label="Remote ID", + description="Unique identifier for WebRTC remote access. " + "Generated automatically and should not be changed.", + required=False, + hidden=True, + ) + ) + + return tuple(entries) + + async def setup(self, config: CoreConfig) -> None: + """Async initialize of module.""" + self.config = config + self.logger.debug("RemoteAccessController.setup() called") # Register API commands immediately self._register_api_commands() @@ -88,6 +143,11 @@ class RemoteAccessManager: # Already set up return + # Check if remote access is enabled in config + if not self.config.get_value(CONF_ENABLED, True): + self.logger.info("Remote access is disabled in configuration") + return + # Check if Home Assistant Cloud is available and active cloud_status = await self._check_ha_cloud_status() if not cloud_status: @@ -99,14 +159,12 @@ class RemoteAccessManager: self.logger.info("Home Assistant Cloud subscription detected, enabling remote access") # Get or generate Remote ID - remote_id_value = self.webserver.config.get_value(_CONF_REMOTE_ID) + remote_id_value = self.config.get_value(CONF_REMOTE_ID) if not remote_id_value: # Generate new Remote ID and save it remote_id_value = generate_remote_id() # Save the Remote ID to config - self.mass.config.set_raw_core_config_value( - "webserver", _CONF_REMOTE_ID, remote_id_value - ) + self.mass.config.set_raw_core_config_value(self.domain, CONF_REMOTE_ID, remote_id_value) self.mass.config.save(immediate=True) self.logger.info("Generated new Remote ID: %s", remote_id_value) @@ -117,8 +175,9 @@ class RemoteAccessManager: self.logger.error("Invalid remote_id type: %s", type(remote_id_value)) return - # Determine local WebSocket URL - bind_port_value = self.webserver.config.get_value("bind_port", 8095) + # Determine local WebSocket URL from webserver config + webserver_config = await self.mass.config.get_core_config("webserver") + bind_port_value = webserver_config.get_value("bind_port", 8095) bind_port = int(bind_port_value) if isinstance(bind_port_value, int) else 8095 local_ws_url = f"ws://localhost:{bind_port}/ws" diff --git a/music_assistant/controllers/webserver/remote_access/gateway.py b/music_assistant/controllers/remote_access/gateway.py similarity index 79% rename from music_assistant/controllers/webserver/remote_access/gateway.py rename to music_assistant/controllers/remote_access/gateway.py index 47b8ecd7..5f8b8561 100644 --- a/music_assistant/controllers/webserver/remote_access/gateway.py +++ b/music_assistant/controllers/remote_access/gateway.py @@ -134,11 +134,18 @@ class WebRTCGateway: for session_id in list(self.sessions.keys()): await self._close_session(session_id) - # Close signaling connection - if self._signaling_ws: - await self._signaling_ws.close() - if self._signaling_session: - await self._signaling_session.close() + # Close signaling connection gracefully + if self._signaling_ws and not self._signaling_ws.closed: + try: + await self._signaling_ws.close() + except Exception: + self.logger.debug("Error closing signaling WebSocket", exc_info=True) + + if self._signaling_session and not self._signaling_session.closed: + try: + await self._signaling_session.close() + except Exception: + self.logger.debug("Error closing signaling session", exc_info=True) # Cancel run task if self._run_task and not self._run_task.done(): @@ -146,6 +153,9 @@ class WebRTCGateway: with contextlib.suppress(asyncio.CancelledError): await self._run_task + self._signaling_ws = None + self._signaling_session = None + async def _run(self) -> None: """Run the main loop with reconnection logic.""" self.logger.debug("WebRTC Gateway _run() loop starting") @@ -189,34 +199,61 @@ class WebRTCGateway: try: self._signaling_ws = await self._signaling_session.ws_connect( self.signaling_url, - heartbeat=45, # Send WebSocket ping every 45 seconds + heartbeat=30, # Send WebSocket ping every 30 seconds autoping=True, # Automatically respond to pings ) + self.logger.debug("WebSocket connection established") + # Small delay to let any previous connection fully close on the server side + # This helps prevent race conditions during reconnection + await asyncio.sleep(0.5) + self.logger.debug("Sending registration") await self._register() self._is_connected = True # Reset reconnect delay on successful connection self._current_reconnect_delay = self._reconnect_delay - self.logger.info("Connected to signaling server") + self.logger.info("Connected and registered with signaling server") # Message loop + self.logger.debug("Entering message loop") async for msg in self._signaling_ws: + self.logger.debug("Received WebSocket message type: %s", msg.type) if msg.type == aiohttp.WSMsgType.TEXT: try: await self._handle_signaling_message(json.loads(msg.data)) except Exception: self.logger.exception("Error handling signaling message") + elif msg.type == aiohttp.WSMsgType.PING: + # WebSocket ping - autoping should handle this, just log + self.logger.debug("Received WebSocket PING") + elif msg.type == aiohttp.WSMsgType.PONG: + # WebSocket pong response - just log + self.logger.debug("Received WebSocket PONG") + elif msg.type == aiohttp.WSMsgType.CLOSE: + # Close frame received + self.logger.warning( + "Signaling server sent close frame: code=%s, reason=%s", + msg.data, + msg.extra, + ) + break elif msg.type == aiohttp.WSMsgType.CLOSED: - self.logger.warning("Signaling server closed connection: %s", msg.extra) + self.logger.warning("Signaling server closed connection") break elif msg.type == aiohttp.WSMsgType.ERROR: - self.logger.error("WebSocket error: %s", msg.extra) + self.logger.error("WebSocket error: %s", self._signaling_ws.exception()) break + else: + self.logger.warning("Unexpected WebSocket message type: %s", msg.type) - self.logger.info("Message loop exited normally") + self.logger.info( + "Message loop exited - WebSocket closed: %s", self._signaling_ws.closed + ) except TimeoutError: self.logger.error("Timeout connecting to signaling server") except aiohttp.ClientError as err: self.logger.error("Failed to connect to signaling server: %s", err) + except Exception: + self.logger.exception("Unexpected error in signaling connection") finally: self._is_connected = False if self._signaling_session: @@ -331,31 +368,74 @@ class WebRTCGateway: if not session: return pc = session.peer_connection + + # Check if peer connection is already closed or closing + if pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Ignoring offer for session %s - connection state: %s", + session_id, + pc.connectionState, + ) + return + sdp = offer.get("sdp") sdp_type = offer.get("type") if not sdp or not sdp_type: self.logger.error("Invalid offer data: missing sdp or type") return - await pc.setRemoteDescription( - RTCSessionDescription( - sdp=str(sdp), - type=str(sdp_type), - ) - ) - answer = await pc.createAnswer() - await pc.setLocalDescription(answer) - if self._signaling_ws: - await self._signaling_ws.send_json( - { - "type": "answer", - "sessionId": session_id, - "data": { - "sdp": pc.localDescription.sdp, - "type": pc.localDescription.type, - }, - } + + try: + await pc.setRemoteDescription( + RTCSessionDescription( + sdp=str(sdp), + type=str(sdp_type), + ) ) + # Check again if session was closed during setRemoteDescription + if session_id not in self.sessions or pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Session %s closed during setRemoteDescription, aborting offer handling", + session_id, + ) + return + + answer = await pc.createAnswer() + + # Check again before setLocalDescription + if session_id not in self.sessions or pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Session %s closed during createAnswer, aborting offer handling", + session_id, + ) + return + + await pc.setLocalDescription(answer) + + # Final check before sending answer + if session_id not in self.sessions or pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Session %s closed during setLocalDescription, skipping answer transmission", + session_id, + ) + return + + if self._signaling_ws: + await self._signaling_ws.send_json( + { + "type": "answer", + "sessionId": session_id, + "data": { + "sdp": pc.localDescription.sdp, + "type": pc.localDescription.type, + }, + } + ) + except Exception: + self.logger.exception("Error handling offer for session %s", session_id) + # Clean up the session on error + await self._close_session(session_id) + async def _handle_ice_candidate(self, session_id: str, candidate: dict[str, Any]) -> None: """Handle incoming ICE candidate. @@ -366,6 +446,16 @@ class WebRTCGateway: if not session or not candidate: return + # Check if peer connection is already closed or closing + pc = session.peer_connection + if pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Ignoring ICE candidate for session %s - connection state: %s", + session_id, + pc.connectionState, + ) + return + candidate_str = candidate.get("candidate") sdp_mid = candidate.get("sdpMid") sdp_mline_index = candidate.get("sdpMLineIndex") @@ -388,9 +478,20 @@ class WebRTCGateway: ) # Parse the candidate string to populate the fields ice_candidate.candidate = str(candidate_str) # type: ignore[attr-defined] + + # Check if session was closed before adding candidate + if session_id not in self.sessions or pc.connectionState in ("closed", "failed"): + self.logger.debug( + "Session %s closed before adding ICE candidate, skipping", + session_id, + ) + return + await session.peer_connection.addIceCandidate(ice_candidate) except Exception: - self.logger.exception("Failed to add ICE candidate: %s", candidate) + self.logger.exception( + "Failed to add ICE candidate for session %s: %s", session_id, candidate + ) async def _setup_data_channel(self, session: WebRTCSession) -> None: """Set up data channel and bridge to local WebSocket. diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index 0aa8aa8e..e46c00e3 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -56,7 +56,6 @@ from .helpers.auth_middleware import ( set_current_user, ) from .helpers.auth_providers import BuiltinLoginProvider -from .remote_access import RemoteAccessManager from .websocket_client import WebsocketClientHandler if TYPE_CHECKING: @@ -92,7 +91,6 @@ class WebserverController(CoreController): ) self.manifest.icon = "web-box" self.auth = AuthenticationManager(self) - self.remote_access = RemoteAccessManager(self) @property def base_url(self) -> str: @@ -199,15 +197,6 @@ class WebserverController(CoreController): category="advanced", hidden=not any(provider.domain == "hass" for provider in self.mass.providers), ), - ConfigEntry( - key="remote_id", - type=ConfigEntryType.STRING, - label="Remote ID", - description="Unique identifier for WebRTC remote access. " - "Generated automatically and should not be changed.", - required=False, - hidden=True, - ), ] ) @@ -242,6 +231,7 @@ class WebserverController(CoreController): routes.append(("OPTIONS", "/info", self._handle_cors_preflight)) # add logging routes.append(("GET", "/music-assistant.log", self._handle_application_log)) + routes.append(("OPTIONS", "/music-assistant.log", self._handle_cors_preflight)) # add websocket api routes.append(("GET", "/ws", self._handle_ws_client)) # also host the image proxy on the webserver @@ -275,10 +265,7 @@ class WebserverController(CoreController): # 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() - # Initialize remote access manager - await self.remote_access.setup() # start the webserver all_ip_addresses = await get_ip_addresses() default_publish_ip = all_ip_addresses[0] @@ -391,7 +378,6 @@ class WebserverController(CoreController): await client.disconnect() await self._server.close() await self.auth.close() - await self.remote_access.close() def register_websocket_client(self, client: WebsocketClientHandler) -> None: """Register a WebSocket client for tracking.""" @@ -561,7 +547,15 @@ class WebserverController(CoreController): 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") + return web.Response( + text=log_data, + content_type="text/text", + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + ) async def _handle_api_intro(self, request: web.Request) -> web.Response: """Handle request for API introduction/documentation page.""" diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 3b04bc89..19d29bb6 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -43,6 +43,7 @@ from music_assistant.controllers.metadata import MetaDataController from music_assistant.controllers.music import MusicController from music_assistant.controllers.player_queues import PlayerQueuesController from music_assistant.controllers.players.player_controller import PlayerController +from music_assistant.controllers.remote_access import RemoteAccessController from music_assistant.controllers.streams import StreamsController from music_assistant.controllers.webserver import WebserverController from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user @@ -111,6 +112,7 @@ class MusicAssistant: players: PlayerController player_queues: PlayerQueuesController streams: StreamsController + remote_access: RemoteAccessController _aiobrowser: AsyncServiceBrowser def __init__(self, storage_path: str, cache_path: str, safe_mode: bool = False) -> None: @@ -166,6 +168,7 @@ class MusicAssistant: self.players = PlayerController(self) self.player_queues = PlayerQueuesController(self) self.streams = StreamsController(self) + self.remote_access = RemoteAccessController(self) # add manifests for core controllers for controller_name in CONFIGURABLE_CORE_CONTROLLERS: controller: CoreController = getattr(self, controller_name) @@ -181,6 +184,8 @@ class MusicAssistant: # not yet available while we're starting (or performing migrations) self._register_api_commands() await self.webserver.setup(await self.config.get_core_config("webserver")) + # setup remote access after webserver (it needs webserver's port) + await self.remote_access.setup(await self.config.get_core_config("remote_access")) # setup discovery await self._setup_discovery() # load providers @@ -202,6 +207,7 @@ class MusicAssistant: ) # stop core controllers await self.streams.close() + await self.remote_access.close() await self.webserver.close() await self.metadata.close() await self.music.close() -- 2.34.1