Prefer local connection before webrtc for sendspin
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 7 Dec 2025 03:05:39 +0000 (04:05 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 7 Dec 2025 03:05:39 +0000 (04:05 +0100)
music_assistant/controllers/webserver/remote_access/__init__.py
music_assistant/controllers/webserver/remote_access/gateway.py
music_assistant/providers/sendspin/provider.py

index 88c7ca07cb767e1a863a19cc7ca3220148d997e1..60421d51ca28c50e41fcbc53b7875a2a8ef3f31f 100644 (file)
@@ -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()
index b1ca4abd6a19fcd5911eacb06b62f40932c17f5f..6fa9b2608ac5848b62b0e8024b41fa6b4a47a2d5 100644 (file)
@@ -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:
index 05f92663b1410951e23435acd2e367ba4e64a41c..cf6b4bc9a8d065485bef28b6448eccc29a612805 100644 (file)
@@ -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)