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
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}"
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))
]
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):
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
)
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"]
# 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:
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).
# 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()
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
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.
: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
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
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
: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)
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.
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"}')
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:
)
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"):
)
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":
)
and event.object_id
and event.object_id not in self._authenticated_user.player_filter
+ and event.object_id != self._sendspin_player_id
):
return
"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."""