From: Marcel van der Veldt Date: Sun, 7 Dec 2025 03:05:39 +0000 (+0100) Subject: Prefer local connection before webrtc for sendspin X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=eab163fe34816f9469e80bdf8cf4290f0af9fbca;p=music-assistant-server.git Prefer local connection before webrtc for sendspin --- diff --git a/music_assistant/controllers/webserver/remote_access/__init__.py b/music_assistant/controllers/webserver/remote_access/__init__.py index 88c7ca07..60421d51 100644 --- a/music_assistant/controllers/webserver/remote_access/__init__.py +++ b/music_assistant/controllers/webserver/remote_access/__init__.py @@ -34,6 +34,9 @@ CONF_KEY_MAIN = "remote_access" CONF_REMOTE_ID = "remote_id" CONF_ENABLED = "enabled" +TASK_ID_START_GATEWAY = "remote_access_start_gateway" +STARTUP_DELAY = 5 + @dataclass class RemoteAccessInfo(DataClassDictMixin): @@ -76,17 +79,31 @@ class RemoteAccessManager: self._register_api_commands() self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED) if self._enabled: - await self.start() + self._schedule_start() async def close(self) -> None: """Cleanup on exit.""" + self.mass.cancel_timer(TASK_ID_START_GATEWAY) await self.stop() for unload_cb in self._on_unload_callbacks: unload_cb() - async def start(self) -> None: - """Start the remote access gateway.""" - if self.is_running or not self._enabled: + def _schedule_start(self) -> None: + """Schedule a debounced gateway start.""" + self.logger.debug("Scheduling remote access gateway start in %s seconds", STARTUP_DELAY) + self.mass.call_later( + STARTUP_DELAY, + self._start_gateway(), + task_id=TASK_ID_START_GATEWAY, + ) + + async def _start_gateway(self) -> None: + """Start the remote access gateway (internal implementation).""" + if self.is_running: + self.logger.debug("Gateway already running, skipping start") + return + if not self._enabled: + self.logger.debug("Remote access disabled, skipping start") return base_url = self.mass.webserver.base_url @@ -113,7 +130,6 @@ class RemoteAccessManager: ) await self.gateway.start() - self._enabled = True async def stop(self) -> None: """Stop the remote access gateway.""" @@ -126,16 +142,22 @@ class RemoteAccessManager: :param event: The providers updated event. """ - if not self.is_running or not self._enabled: + if not self._enabled: + return + + # If not running yet, schedule start (debounced) + if not self.is_running: + self._schedule_start() return + # Check if HA Cloud status changed ha_cloud_available, ice_servers = await self._get_ha_cloud_status() new_using_ha_cloud = bool(ha_cloud_available and ice_servers) if new_using_ha_cloud != self._using_ha_cloud: self.logger.info("HA Cloud status changed, restarting remote access") await self.stop() - self.mass.create_task(self.start()) + self._schedule_start() async def _get_ha_cloud_status(self) -> tuple[bool, list[dict[str, str]] | None]: """Get Home Assistant Cloud status and ICE servers. @@ -235,7 +257,7 @@ class RemoteAccessManager: self._enabled = enabled self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", enabled) if self._enabled and not self.is_running: - await self.start() + await self._start_gateway() elif not self._enabled and self.is_running: await self.stop() return await get_remote_access_info() diff --git a/music_assistant/controllers/webserver/remote_access/gateway.py b/music_assistant/controllers/webserver/remote_access/gateway.py index b1ca4abd..6fa9b260 100644 --- a/music_assistant/controllers/webserver/remote_access/gateway.py +++ b/music_assistant/controllers/webserver/remote_access/gateway.py @@ -112,6 +112,7 @@ class WebRTCGateway: self._current_reconnect_delay = 5 self._run_task: asyncio.Task[None] | None = None self._is_connected = False + self._connecting = False @property def is_running(self) -> bool: @@ -204,21 +205,19 @@ class WebRTCGateway: async def _connect_to_signaling(self) -> None: """Connect to the signaling server.""" + if self._connecting: + self.logger.warning("Already connecting to signaling server, skipping") + return + self._connecting = True self.logger.info("Connecting to signaling server: %s", self.signaling_url) try: self._signaling_ws = await self.http_session.ws_connect( self.signaling_url, - heartbeat=30, # Send WebSocket ping every 30 seconds - autoping=True, # Automatically respond to pings + heartbeat=None, ) - 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("WebSocket connection established, id=%s", id(self._signaling_ws)) self.logger.debug("Sending registration") await self._register() - # Note: _is_connected is set to True when we receive "registered" confirmation - # Reset reconnect delay on successful connection self._current_reconnect_delay = self._reconnect_delay self.logger.info("Registration sent, waiting for confirmation...") @@ -254,8 +253,12 @@ class WebRTCGateway: else: self.logger.warning("Unexpected WebSocket message type: %s", msg.type) + ws_exception = self._signaling_ws.exception() self.logger.info( - "Message loop exited - WebSocket closed: %s", self._signaling_ws.closed + "Message loop exited - WebSocket closed: %s, close_code: %s, exception: %s", + self._signaling_ws.closed, + self._signaling_ws.close_code, + ws_exception, ) except TimeoutError: self.logger.error("Timeout connecting to signaling server") @@ -265,6 +268,7 @@ class WebRTCGateway: self.logger.exception("Unexpected error in signaling connection") finally: self._is_connected = False + self._connecting = False self._signaling_ws = None async def _register(self) -> None: diff --git a/music_assistant/providers/sendspin/provider.py b/music_assistant/providers/sendspin/provider.py index 05f92663..cf6b4bc9 100644 --- a/music_assistant/providers/sendspin/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -10,16 +10,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, cast import aiohttp -from aiortc import ( - RTCConfiguration, - RTCIceServer, - RTCPeerConnection, - RTCSessionDescription, -) +from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription from aiortc.sdp import candidate_from_sdp from aiosendspin.server import ClientAddedEvent, ClientRemovedEvent, SendspinEvent, SendspinServer from music_assistant_models.enums import ProviderFeature +from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user from music_assistant.mass import MusicAssistant from music_assistant.models.player_provider import PlayerProvider from music_assistant.providers.sendspin.player import SendspinPlayer @@ -62,6 +58,9 @@ class SendspinProvider(PlayerProvider): # WebRTC signaling commands for Sendspin connections # this is used to establish WebRTC DataChannels with Sendspin clients # for example the WebPlayer in the Music Assistant frontend or supported (mobile) apps + self.mass.register_api_command( + "sendspin/connection_info", self.handle_get_connection_info + ), self.mass.register_api_command("sendspin/connect", self.handle_webrtc_connect), self.mass.register_api_command("sendspin/ice", self.handle_webrtc_ice), self.mass.register_api_command("sendspin/disconnect", self.handle_webrtc_disconnect), @@ -223,6 +222,9 @@ class SendspinProvider(PlayerProvider): "type": pc.localDescription.type, }, "ice_candidates": ice_candidates, + # Include local WebSocket URL for direct connection attempts + # Frontend can try this first before falling back to WebRTC + "local_ws_url": f"ws://{self.mass.streams.publish_ip}:8927/sendspin", } async def handle_webrtc_ice( @@ -287,6 +289,38 @@ class SendspinProvider(PlayerProvider): """ return await self.mass.webserver.remote_access.get_ice_servers() + async def handle_get_connection_info(self, client_id: str | None = None) -> dict[str, Any]: + """ + Get connection info for Sendspin. + + Returns the local WebSocket URL for direct connection attempts, + and ICE servers for WebRTC fallback. + + The frontend should try the local WebSocket URL first for lower latency, + and fall back to WebRTC if the direct connection fails. + + :param client_id: Optional Sendspin client ID for auto-whitelisting. + :return: Dictionary with local_ws_url and ice_servers. + """ + # Auto-whitelist the player for users with player filters enabled + # This allows users with restricted player access to still use the web player + if client_id and (user := get_current_user()): + if user.player_filter and client_id not in user.player_filter: + self.logger.debug( + "Auto-whitelisting Sendspin player %s for user %s", + client_id, + user.username, + ) + new_filter = [*user.player_filter, client_id] + await self.mass.webserver.auth.update_user_filters( + user, player_filter=new_filter, provider_filter=None + ) + + return { + "local_ws_url": f"ws://{self.mass.streams.publish_ip}:8927/sendspin", + "ice_servers": await self.mass.webserver.remote_access.get_ice_servers(), + } + async def _close_webrtc_session(self, session_id: str) -> None: """Close a WebRTC session and clean up resources.""" session = self._webrtc_sessions.pop(session_id, None)