Auto whitelist sendspin webplayer (jnstead of modifying player fiter) (#3026)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 26 Jan 2026 23:04:10 +0000 (00:04 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Jan 2026 23:04:10 +0000 (00:04 +0100)
music_assistant/controllers/players/player_controller.py
music_assistant/controllers/webserver/controller.py
music_assistant/controllers/webserver/helpers/auth_middleware.py
music_assistant/controllers/webserver/remote_access/__init__.py
music_assistant/controllers/webserver/remote_access/gateway.py
music_assistant/controllers/webserver/sendspin_proxy.py
music_assistant/controllers/webserver/websocket_client.py
music_assistant/providers/sendspin/player.py

index af8744f2b6703a6b87953a9aaedf1dc20d5566d0..793b31872d5f61598520fbe4a2d670692893298b 100644 (file)
@@ -72,7 +72,10 @@ from music_assistant.constants import (
     CONF_PRE_ANNOUNCE_CHIME_URL,
     SYNCGROUP_PREFIX,
 )
-from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
+from music_assistant.controllers.webserver.helpers.auth_middleware import (
+    get_current_user,
+    get_sendspin_player_id,
+)
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.helpers.throttle_retry import Throttler
@@ -155,10 +158,12 @@ def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
                 return
 
             current_user = get_current_user()
+            current_sendspin_player = get_sendspin_player_id()
             if (
                 current_user
                 and current_user.player_filter
                 and player.player_id not in current_user.player_filter
+                and player.player_id != current_sendspin_player
             ):
                 msg = (
                     f"{current_user.username} does not have access to player {player.display_name}"
@@ -264,13 +269,18 @@ class PlayerController(CoreController):
             if current_user and current_user.role != UserRole.ADMIN
             else None
         )
+        current_sendspin_player = get_sendspin_player_id()
         return [
             player
             for player in self._players.values()
             if (player.available or return_unavailable)
             and (player.enabled or return_disabled)
             and (provider_filter is None or player.provider.instance_id == provider_filter)
-            and (not user_filter or player.player_id in user_filter)
+            and (
+                not user_filter
+                or player.player_id in user_filter
+                or player.player_id == current_sendspin_player
+            )
             and (return_sync_groups or not isinstance(player, SyncGroupPlayer))
         ]
 
@@ -344,7 +354,13 @@ class PlayerController(CoreController):
             if current_user and current_user.role != UserRole.ADMIN
             else None
         )
-        if current_user and user_filter and player_id not in user_filter:
+        current_sendspin_player = get_sendspin_player_id()
+        if (
+            current_user
+            and user_filter
+            and player_id not in user_filter
+            and player_id != current_sendspin_player
+        ):
             msg = f"{current_user.username} does not have access to player {player_id}"
             raise InsufficientPermissions(msg)
         if player := self.get(player_id, raise_unavailable):
@@ -374,8 +390,14 @@ class PlayerController(CoreController):
             if current_user and current_user.role != UserRole.ADMIN
             else None
         )
+        current_sendspin_player = get_sendspin_player_id()
         if player := self.get_player_by_name(name):
-            if current_user and user_filter and player.player_id not in user_filter:
+            if (
+                current_user
+                and user_filter
+                and player.player_id not in user_filter
+                and player.player_id != current_sendspin_player
+            ):
                 msg = f"{current_user.username} does not have access to player {player.player_id}"
                 raise InsufficientPermissions(msg)
             return player.state
index c83bff8a57023eb0c8a8911b85bacfa01608ee54..db1d87f06f4ac6767e7af1a798edef99671871a6 100644 (file)
@@ -413,6 +413,49 @@ class WebserverController(CoreController):
                 )
                 client._cancel()
 
+    def set_sendspin_player_for_user(self, user_id: str, player_id: str) -> None:
+        """Set the sendspin player_id on websocket clients for a specific user.
+
+        This is called by the sendspin proxy when a client connects, allowing
+        the player controller to auto-whitelist the player for that user's session.
+
+        :param user_id: The user ID to set the sendspin player for.
+        :param player_id: The sendspin player ID to set.
+        """
+        for client in list(self.clients):
+            if client._authenticated_user and client._authenticated_user.user_id == user_id:
+                client._sendspin_player_id = player_id
+                self.logger.debug(
+                    "Set sendspin player %s for websocket client of user %s",
+                    player_id,
+                    client._authenticated_user.username,
+                )
+
+    def set_sendspin_player_for_webrtc_session(self, session_id: str, player_id: str) -> None:
+        """Set the sendspin player_id on a websocket client for a WebRTC session.
+
+        This is called by the WebRTC gateway when it extracts the client_id from
+        the sendspin auth message, allowing auto-whitelisting of the player.
+
+        :param session_id: The WebRTC session ID.
+        :param player_id: The sendspin player ID to set.
+        """
+        for client in list(self.clients):
+            if client._webrtc_session_id == session_id:
+                client._sendspin_player_id = player_id
+                username = (
+                    client._authenticated_user.username
+                    if client._authenticated_user
+                    else "unauthenticated"
+                )
+                self.logger.debug(
+                    "Set sendspin player %s for WebRTC session %s (user: %s)",
+                    player_id,
+                    session_id,
+                    username,
+                )
+                return
+
     async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
         """Serve short preview sample."""
         provider_instance_id_or_domain = request.query["provider"]
index c70e137b6f04a172a6a1278ccecff1f1cc971105..1c975a1c13bf479d088f93b79ae99705ea189d4b 100644 (file)
@@ -24,6 +24,8 @@ 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)
+# ContextVar for tracking the sendspin player associated with the current connection
+sendspin_player_id: ContextVar[str | None] = ContextVar("sendspin_player_id", default=None)
 
 
 async def get_authenticated_user(request: web.Request) -> User | None:
@@ -190,6 +192,22 @@ def set_current_token(token: str | None) -> None:
     current_token.set(token)
 
 
+def get_sendspin_player_id() -> str | None:
+    """Get the sendspin player ID associated with the current connection.
+
+    :return: The sendspin player ID or None if not a sendspin connection.
+    """
+    return sendspin_player_id.get()
+
+
+def set_sendspin_player_id(player_id: str | None) -> None:
+    """Set the sendspin player ID for the current connection.
+
+    :param player_id: The sendspin player ID to set.
+    """
+    sendspin_player_id.set(player_id)
+
+
 def is_request_from_ingress(request: web.Request) -> bool:
     """Check if request is coming from Home Assistant Ingress (internal network).
 
index 1cff751b55aa3c8e0054d61e389ced601b6f3e67..bae3999206f1abea8d1e273ea78747cc5c580a53 100644 (file)
@@ -131,6 +131,8 @@ class RemoteAccessManager:
             # Pass callback to get fresh ICE servers for each client connection
             # This ensures TURN credentials are always valid
             ice_servers_callback=self.get_ice_servers if ha_cloud_available else None,
+            # Pass callback to set sendspin player on websocket client
+            set_sendspin_player_callback=self.webserver.set_sendspin_player_for_webrtc_session,
         )
 
         await self.gateway.start()
index 4ec9261ddbe7225c2234eb3a068f896bc5d5e9e7..68bed470feb2229bb38158a18a4e229bf0cbf70c 100644 (file)
@@ -48,6 +48,7 @@ class WebRTCSession:
     sendspin_channel: Any = None
     sendspin_ws: Any = None
     sendspin_queue: asyncio.Queue[str | bytes] = field(default_factory=asyncio.Queue)
+    sendspin_player_id: str | None = None  # Extracted from first sendspin auth message
     sendspin_to_local_task: asyncio.Task[None] | None = None
     sendspin_from_local_task: asyncio.Task[None] | None = None
 
@@ -84,6 +85,7 @@ class WebRTCGateway:
         sendspin_url: str = "ws://localhost:8927/sendspin",
         ice_servers: list[dict[str, Any]] | None = None,
         ice_servers_callback: Callable[[], Awaitable[list[dict[str, Any]]]] | None = None,
+        set_sendspin_player_callback: Callable[[str, str], None] | None = None,
     ) -> None:
         """
         Initialize the WebRTC Gateway.
@@ -96,6 +98,7 @@ class WebRTCGateway:
         :param sendspin_url: Internal Sendspin WebSocket URL to bridge to.
         :param ice_servers: List of ICE server configurations (used at registration time).
         :param ice_servers_callback: Optional callback to fetch fresh ICE servers for each session.
+        :param set_sendspin_player_callback: Callback to set sendspin player for a session.
         """
         self.http_session = http_session
         self.signaling_url = signaling_url
@@ -105,6 +108,7 @@ class WebRTCGateway:
         self._certificate = certificate
         self.logger = LOGGER
         self._ice_servers_callback = ice_servers_callback
+        self._set_sendspin_player_callback = set_sendspin_player_callback
 
         # Static ICE servers used at registration time (relayed to clients via signaling server)
         self.ice_servers = ice_servers or self.DEFAULT_ICE_SERVERS
@@ -536,7 +540,9 @@ class WebRTCGateway:
         if not channel:
             return
         try:
-            session.local_ws = await self.http_session.ws_connect(self.local_ws_url)
+            # Include session_id in URL so server can track WebRTC sessions
+            ws_url = f"{self.local_ws_url}?webrtc_session_id={session.session_id}"
+            session.local_ws = await self.http_session.ws_connect(ws_url)
             loop = asyncio.get_event_loop()
 
             # Store task references for proper cleanup
@@ -761,9 +767,17 @@ class WebRTCGateway:
 
         :param session: The WebRTC session.
         """
+        first_message = True
         try:
             while session.sendspin_ws and not session.sendspin_ws.closed:
                 message = await session.sendspin_queue.get()
+
+                # Check only the first message for client_id extraction
+                if first_message:
+                    first_message = False
+                    if isinstance(message, str):
+                        self._try_extract_sendspin_client_id(session, message)
+
                 if session.sendspin_ws and not session.sendspin_ws.closed:
                     if isinstance(message, bytes):
                         await session.sendspin_ws.send_bytes(message)
@@ -778,6 +792,31 @@ class WebRTCGateway:
         except Exception:
             self.logger.exception("Error forwarding sendspin to local")
 
+    def _try_extract_sendspin_client_id(self, session: WebRTCSession, message: str) -> None:
+        """Try to extract client_id from sendspin auth message and set on websocket client.
+
+        :param session: The WebRTC session.
+        :param message: The first sendspin message (expected to be auth).
+        """
+        try:
+            data = json.loads(message)
+            if data.get("type") != "auth":
+                return  # Not an auth message
+
+            # This is an auth message - extract client_id if present
+            if client_id := data.get("client_id"):
+                session.sendspin_player_id = client_id
+                self.logger.debug(
+                    "Extracted sendspin player %s for session %s",
+                    client_id,
+                    session.session_id,
+                )
+                # Use callback to set sendspin player on the websocket client
+                if self._set_sendspin_player_callback:
+                    self._set_sendspin_player_callback(session.session_id, client_id)
+        except (json.JSONDecodeError, TypeError):
+            pass  # Not valid JSON, ignore
+
     async def _forward_sendspin_from_local(self, session: WebRTCSession) -> None:
         """Forward messages from internal sendspin server to sendspin DataChannel.
 
index 3e55154db05b8516dee1d0f1f3bbf40585359a07..2efe07dfdb1fac551a2b45ef901aa7210825c78d 100644 (file)
@@ -141,16 +141,13 @@ class SendspinProxyHandler:
             await wsock.close(code=4001, message=b"Invalid or expired token")
             return None
 
-        # Auto-whitelist player for users with player filters
+        # Set the sendspin player_id on the user's websocket client(s)
+        # This allows the player controller to auto-whitelist this (web)player
+        # without modifying the user's player_filter list
         client_id = auth_data.get("client_id")
-        if client_id and 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.webserver.auth.update_user_filters(
-                user, player_filter=new_filter, provider_filter=None
-            )
+        if client_id:
+            self.webserver.set_sendspin_player_for_user(user.user_id, client_id)
+            self.logger.debug("Registered sendspin player %s for user %s", client_id, user.username)
 
         self.logger.debug("Sendspin proxy authenticated user: %s", user.username)
         await wsock.send_str('{"type": "auth_ok"}')
index b0532cc3386a78ccd488f08239dbeb2f22a14a07..610d9e087770a390239fc145511f6f1b0de98259 100644 (file)
@@ -27,7 +27,12 @@ from music_assistant_models.errors import (
 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
+from .helpers.auth_middleware import (
+    is_request_from_ingress,
+    set_current_token,
+    set_current_user,
+    set_sendspin_player_id,
+)
 from .helpers.auth_providers import get_ha_user_details, get_ha_user_role
 
 if TYPE_CHECKING:
@@ -57,8 +62,11 @@ class WebsocketClientHandler:
         )
         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._sendspin_player_id: str | None = None  # Set if client is a sendspin web player
         self._is_ingress = is_request_from_ingress(request)
         self._events_unsub_callback: Any = None  # Will be set after authentication
+        # Track WebRTC session ID if this is a WebRTC gateway connection
+        self._webrtc_session_id: str | None = request.query.get("webrtc_session_id")
         # 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"):
@@ -194,9 +202,10 @@ class WebsocketClientHandler:
                 )
                 return
 
-            # Set user and token in context for API methods
+            # Set user, token, and sendspin player in context for API methods
             set_current_user(self._authenticated_user)
             set_current_token(self._current_token)
+            set_sendspin_player_id(self._sendspin_player_id)
 
             # Check role if required
             if handler.required_role == "admin":
@@ -432,6 +441,7 @@ class WebsocketClientHandler:
                 )
                 and event.object_id
                 and event.object_id not in self._authenticated_user.player_filter
+                and event.object_id != self._sendspin_player_id
             ):
                 return
 
index 359dc1b07434bb64fc19135be51ab87ce303ae39..c304f71dda2ce287b42df30919e3f1e5794dd62e 100644 (file)
@@ -227,6 +227,7 @@ class SendspinPlayer(Player):
             "PWA ("  # The PWA App
         )
         self._attr_expose_to_ha_by_default = not self.is_web_player
+        self._attr_hidden_by_default = self.is_web_player
 
     def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
         """Event callback registered to the sendspin server."""