From 12a752b3c12d73230fdba8089efe3b48d14ffa66 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 27 Jan 2026 00:04:10 +0100 Subject: [PATCH] Auto whitelist sendspin webplayer (jnstead of modifying player fiter) (#3026) --- .../controllers/players/player_controller.py | 30 +++++++++++-- .../controllers/webserver/controller.py | 43 +++++++++++++++++++ .../webserver/helpers/auth_middleware.py | 18 ++++++++ .../webserver/remote_access/__init__.py | 2 + .../webserver/remote_access/gateway.py | 41 +++++++++++++++++- .../controllers/webserver/sendspin_proxy.py | 15 +++---- .../controllers/webserver/websocket_client.py | 14 +++++- music_assistant/providers/sendspin/player.py | 1 + 8 files changed, 148 insertions(+), 16 deletions(-) diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index af8744f2..793b3187 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -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 diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index c83bff8a..db1d87f0 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -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"] diff --git a/music_assistant/controllers/webserver/helpers/auth_middleware.py b/music_assistant/controllers/webserver/helpers/auth_middleware.py index c70e137b..1c975a1c 100644 --- a/music_assistant/controllers/webserver/helpers/auth_middleware.py +++ b/music_assistant/controllers/webserver/helpers/auth_middleware.py @@ -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). diff --git a/music_assistant/controllers/webserver/remote_access/__init__.py b/music_assistant/controllers/webserver/remote_access/__init__.py index 1cff751b..bae39992 100644 --- a/music_assistant/controllers/webserver/remote_access/__init__.py +++ b/music_assistant/controllers/webserver/remote_access/__init__.py @@ -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() diff --git a/music_assistant/controllers/webserver/remote_access/gateway.py b/music_assistant/controllers/webserver/remote_access/gateway.py index 4ec9261d..68bed470 100644 --- a/music_assistant/controllers/webserver/remote_access/gateway.py +++ b/music_assistant/controllers/webserver/remote_access/gateway.py @@ -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. diff --git a/music_assistant/controllers/webserver/sendspin_proxy.py b/music_assistant/controllers/webserver/sendspin_proxy.py index 3e55154d..2efe07df 100644 --- a/music_assistant/controllers/webserver/sendspin_proxy.py +++ b/music_assistant/controllers/webserver/sendspin_proxy.py @@ -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"}') diff --git a/music_assistant/controllers/webserver/websocket_client.py b/music_assistant/controllers/webserver/websocket_client.py index b0532cc3..610d9e08 100644 --- a/music_assistant/controllers/webserver/websocket_client.py +++ b/music_assistant/controllers/webserver/websocket_client.py @@ -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 diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 359dc1b0..c304f71d 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -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.""" -- 2.34.1