/
/
/
1"""
2Sendspin Bridge for Chromecast - allows Sendspin to stream to Chromecast devices.
3
4This module enables Chromecast devices to be controlled via the Sendspin protocol.
5Unlike the AirPlay bridge, audio is NOT streamed through this bridge. Instead,
6the bridge launches the Sendspin Cast Receiver app on the Chromecast, which has
7a built-in JS Sendspin client that connects directly to the server via WebSocket.
8
9The bridge:
101. Registers Chromecast players as external Sendspin clients (using MAC as client_id)
112. The Sendspin provider creates a SendspinPlayer for this external client
123. Protocol linking parents the SendspinPlayer next to the ChromecastPlayer via the
13 declared underlying player (derived-transport edge)
144. When playback is requested, the Cast app is launched and connects to the server
155. The server upgrades the client from bridge role to the JS client's player@v1 role
16"""
17
18from __future__ import annotations
19
20import asyncio
21import logging
22from collections.abc import Callable
23from contextlib import suppress
24from typing import TYPE_CHECKING, Any, cast
25
26from aiosendspin.models.core import ClientHelloPayload
27from aiosendspin.models.core import DeviceInfo as SendspinDeviceInfo
28from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat
29from aiosendspin.models.types import AudioCodec, GoodbyeReason
30from aiosendspin.server import ClientRemovedEvent
31from music_assistant_models.enums import EventType, IdentifierType
32from music_assistant_models.errors import PlayerCommandFailed
33from pychromecast.controllers import BaseController
34
35from music_assistant.constants import (
36 CONF_ENABLED,
37 CONF_PLAYERS,
38 CONF_PROTOCOL_EXPERIMENTAL_NOTE,
39 CONF_PROTOCOL_PARENT_ID,
40 SENDSPIN_SERVER_PORT,
41)
42from music_assistant.helpers.util import format_ip_for_url, is_valid_mac_address
43from music_assistant.providers.chromecast.constants import get_cast_model_static_delay
44from music_assistant.providers.sendspin.bridge_manager import SendspinBridgeManagerBase
45from music_assistant.providers.sendspin.bridge_role import (
46 BRIDGE_BIT_DEPTH,
47 BRIDGE_CHANNELS,
48 BRIDGE_ROLE_ID,
49 BRIDGE_SAMPLE_RATE,
50 BridgePlayerRole,
51)
52from music_assistant.providers.sendspin.constants import (
53 BRIDGE_PREFIX,
54 CONF_SENDSPIN_STATIC_DELAY,
55)
56from music_assistant.providers.sendspin.helpers import (
57 bridge_client_id_from_mac,
58 bridge_client_id_from_uuid,
59)
60
61from .constants import (
62 CONF_SENDSPIN_OPT_OUT_PENDING,
63 CONF_SENDSPIN_UNSUPPORTED,
64 SENDSPIN_CAST_APP_ID,
65 SENDSPIN_CAST_BLOCKLIST,
66 SENDSPIN_CAST_EXPERIMENTAL_NOTE,
67 SENDSPIN_CAST_NAMESPACE,
68 SENDSPIN_LINK_WAIT_INTERVAL,
69 SENDSPIN_LINK_WAIT_TRIES,
70)
71
72if TYPE_CHECKING:
73 from aiosendspin.server import (
74 ExternalStreamStartRequest,
75 SendspinClient,
76 SendspinEvent,
77 SendspinServer,
78 )
79 from music_assistant_models.event import MassEvent
80 from pychromecast.generated.cast_channel_pb2 import CastMessage
81
82 from music_assistant.models.player import Player
83 from music_assistant.providers.sendspin.provider import SendspinProvider
84
85 from .player import ChromecastPlayer
86 from .provider import ChromecastProvider
87
88
89_CAST_LOG_LEVEL_MAP: dict[str, int] = {
90 "error": logging.ERROR,
91 "warn": logging.WARNING,
92 "info": logging.INFO,
93 "debug": logging.DEBUG,
94}
95
96_BRIDGE_POLICY_STATE_FIELDS = {
97 "device_info.identifiers",
98 "device_info.manufacturer",
99 "device_info.model",
100 "output_protocols",
101 "type",
102}
103
104
105class SendspinCastController(BaseController):
106 """
107 Handles messages from the Sendspin Cast receiver app.
108
109 Processes receiver_log messages (forwarded console output) and
110 status messages (connection state, errors) from the Cast app.
111 """
112
113 def __init__(
114 self,
115 logger: logging.Logger,
116 on_fatal_audio_error: Callable[[], None] | None = None,
117 on_cast_connected: Callable[[], None] | None = None,
118 ) -> None:
119 """
120 Initialize the controller.
121
122 :param logger: Logger to forward Cast receiver messages to.
123 :param on_fatal_audio_error: Callback when Cast app reports audio is unsupported.
124 :param on_cast_connected: Callback when Cast app reports it connected to Sendspin.
125 """
126 super().__init__(SENDSPIN_CAST_NAMESPACE)
127 self._log = logger
128 self._on_fatal_audio_error = on_fatal_audio_error
129 self._on_cast_connected = on_cast_connected
130
131 def receive_message(self, _message: CastMessage, data: dict[str, Any]) -> bool:
132 """
133 Handle incoming messages on the Sendspin namespace.
134
135 :param _message: The raw Cast protocol message.
136 :param data: The parsed JSON payload.
137 """
138 msg_type = data.get("type")
139 if msg_type == "receiver_log":
140 return self._handle_receiver_log(data)
141 if msg_type == "status":
142 return self._handle_status(data)
143 return False
144
145 def _handle_receiver_log(self, data: dict[str, Any]) -> bool:
146 """Forward a receiver console log to the Python logger."""
147 level = _CAST_LOG_LEVEL_MAP.get(data.get("level", ""), logging.DEBUG)
148 self._log.log(level, "[CastApp] %s", data.get("message", ""))
149 if stack := data.get("stack"):
150 self._log.log(level, "[CastApp] %s", stack)
151 return True
152
153 def _handle_status(self, data: dict[str, Any]) -> bool:
154 """
155 Handle a status message from the Cast receiver.
156
157 Only errors are logged. Non-error statuses are sent every second and would be too noisy.
158 """
159 state = data.get("state")
160 message = data.get("message", "")
161 if state == "error":
162 self._log.error("[CastApp] Error: %s", message)
163 if "Audio output is not supported" in message and self._on_fatal_audio_error:
164 self._on_fatal_audio_error()
165 elif state == "connected" and self._on_cast_connected:
166 self._on_cast_connected()
167 return True
168
169
170def get_bridge_client_id(cast_player: ChromecastPlayer) -> str | None:
171 """
172 Get the Sendspin bridge client ID for a Chromecast player.
173
174 Uses MAC address for physical devices, UUID for groups (which have no MAC).
175
176 :param cast_player: The Chromecast player to bridge.
177 :return: The bridge client_id, or None if no valid identifier is available.
178 """
179 if cast_player.cast_info.is_audio_group:
180 return bridge_client_id_from_uuid(str(cast_player.cast_info.uuid))
181 cast_mac = cast_player.cast_info.mac_address
182 if cast_mac and is_valid_mac_address(cast_mac):
183 return bridge_client_id_from_mac(cast_mac)
184 device_mac = cast_player.device_info.mac_address
185 if device_mac and is_valid_mac_address(device_mac):
186 return bridge_client_id_from_mac(device_mac)
187 return None
188
189
190def _toggle_locally_administered_bit(mac_hex: str) -> str | None:
191 """
192 Toggle bit 1 (locally-administered bit) of the first octet of a hex MAC string.
193
194 :param mac_hex: Lowercase 12-char hex MAC without separators (e.g. "5478c9e60da0").
195 :return: The MAC with the LA bit toggled, or None if input is invalid.
196 """
197 if len(mac_hex) != 12:
198 return None
199 try:
200 first_octet = int(mac_hex[:2], 16)
201 toggled = first_octet ^ 0x02
202 return f"{toggled:02x}{mac_hex[2:]}"
203 except ValueError:
204 return None
205
206
207def is_sendspin_cast_blocked(manufacturer: str, model: str) -> bool:
208 """
209 Check if a device is blocked from the Sendspin Cast bridge.
210
211 :param manufacturer: The device manufacturer name.
212 :param model: The device model name.
213 """
214 for blocked_manufacturer, blocked_model in SENDSPIN_CAST_BLOCKLIST:
215 if blocked_manufacturer in (manufacturer, "*") and blocked_model in (model, "*"):
216 return True
217 return False
218
219
220def _build_bridge_hello(cast_player: ChromecastPlayer, bridge_client_id: str) -> ClientHelloPayload:
221 """Build the Sendspin ClientHelloPayload used to register a Chromecast bridge."""
222 return ClientHelloPayload(
223 client_id=bridge_client_id,
224 name=f"{cast_player.display_name} (Cast)",
225 version=1,
226 supported_roles=[BRIDGE_ROLE_ID],
227 device_info=SendspinDeviceInfo(
228 product_name="Chromecast Bridge",
229 manufacturer=cast_player.device_info.manufacturer,
230 ),
231 player_support=ClientHelloPlayerSupport(
232 supported_formats=[
233 SupportedAudioFormat(
234 codec=AudioCodec.PCM,
235 channels=BRIDGE_CHANNELS,
236 sample_rate=BRIDGE_SAMPLE_RATE,
237 bit_depth=BRIDGE_BIT_DEPTH,
238 )
239 ],
240 buffer_capacity=1_000,
241 supported_commands=[],
242 ),
243 )
244
245
246async def _apply_fatal_disconnect(client: SendspinClient, logger: logging.Logger) -> None:
247 """Tear down playback for a dead Cast client: solo â stop, grouped â ungroup."""
248 try:
249 if len(client.group.clients) > 1:
250 await client.ungroup()
251 else:
252 await client.group.stop()
253 except Exception:
254 logger.exception("Failed to tear down dead Cast client %s", client.client_id)
255
256
257class SendspinChromecastBridge:
258 """
259 Manages the Sendspin to Chromecast bridge for a single player.
260
261 This class handles:
262 1. Registering the Chromecast player as an external Sendspin client
263 2. Launching the Sendspin Cast Receiver app when playback is requested
264 3. Sending the server URL and client_id to the Cast app via custom namespace
265
266 The Cast app's built-in JS client then connects to the Sendspin server
267 with the same client_id, and the server handles the reconnection/upgrade.
268 """
269
270 def __init__(
271 self,
272 provider: ChromecastProvider,
273 cast_player: ChromecastPlayer,
274 sendspin_server: SendspinServer,
275 bridge_client_id: str,
276 ) -> None:
277 """
278 Initialize the bridge.
279
280 :param provider: The Chromecast provider instance.
281 :param cast_player: The Chromecast player to bridge.
282 :param sendspin_server: The Sendspin server to register with.
283 :param bridge_client_id: The pre-resolved Sendspin client ID for this device.
284 """
285 self.provider = provider
286 self.mass = provider.mass
287 self.cast_player = cast_player
288 self.sendspin_server = sendspin_server
289 self.logger = provider.logger.getChild(f"bridge.{cast_player.player_id}")
290
291 self._sendspin_client: SendspinClient | None = None
292 self._bridge_client_id: str = bridge_client_id
293 self._bridge_role: BridgePlayerRole | None = None
294 self._launch_task: asyncio.Task[None] | None = None
295 self._log_controller: SendspinCastController | None = None
296 self._cast_app_was_active: bool = False
297 self._cast_app_connected: bool = False
298 self._cast_app_ready: asyncio.Future[None] | None = None
299
300 def reset_cast_app_ready(self) -> asyncio.Future[None]:
301 """
302 Return a fresh cast-ready future, cancelling any prior pending one.
303
304 If the Sendspin Cast app is already running (either previously
305 connected this session, or running from before MA restart), return a
306 future that's already resolved so callers don't wait 30s for a
307 "connected" status that won't fire again.
308 """
309 prior = self._cast_app_ready
310 if prior is not None and not prior.done():
311 prior.cancel()
312 fut: asyncio.Future[None] = self.mass.loop.create_future()
313 already_running = self.cast_player.cc.app_id == SENDSPIN_CAST_APP_ID
314 if self._cast_app_connected or already_running:
315 fut.set_result(None)
316 self._cast_app_ready = fut
317 return fut
318 self._cast_app_ready = fut
319 return fut
320
321 def ensure_cast_app_ready(self) -> asyncio.Future[None]:
322 """
323 Return the current future, creating one only if missing.
324
325 Leaves a resolved future intact so a stream-start fired after a
326 successful connect doesn't replace it with a pending one that
327 nothing will resolve.
328 """
329 fut = self._cast_app_ready
330 if fut is None:
331 fut = self.mass.loop.create_future()
332 self._cast_app_ready = fut
333 return fut
334
335 @property
336 def bridge_client_id(self) -> str:
337 """Return the bridge client_id."""
338 return self._bridge_client_id
339
340 @property
341 def is_registered(self) -> bool:
342 """Return whether the bridge is registered with Sendspin."""
343 return self._sendspin_client is not None
344
345 @property
346 def is_cast_app_active(self) -> bool:
347 """Return whether the Sendspin Cast app is active on the device."""
348 return self.cast_player.cc.app_id == SENDSPIN_CAST_APP_ID
349
350 async def start(self) -> None:
351 """Register the Chromecast player as an external Sendspin client."""
352 hello = _build_bridge_hello(self.cast_player, self._bridge_client_id)
353
354 # Pre-register the Chromecast UUID so the resulting SendspinPlayer
355 # carries it as a CAST_UUID identifier for cross-protocol matching.
356 if sendspin_provider := cast("SendspinProvider | None", self.mass.get_provider("sendspin")):
357 sendspin_provider.register_bridge_identifiers(
358 self._bridge_client_id,
359 {IdentifierType.CAST_UUID: str(self.cast_player.cast_info.uuid)},
360 )
361 sendspin_provider.register_bridge_underlying_player(
362 self._bridge_client_id, self.cast_player.player_id
363 )
364 sendspin_provider.register_bridge_static_delay_default(
365 self._bridge_client_id,
366 get_cast_model_static_delay(
367 self.cast_player.device_info.manufacturer or "",
368 self.cast_player.device_info.model or "",
369 ),
370 )
371
372 self.logger.debug(
373 "Registering Sendspin bridge for %s with client_id=%s",
374 self.cast_player.display_name,
375 self._bridge_client_id,
376 )
377
378 self._sendspin_client = self.sendspin_server.register_external_player(
379 hello, on_stream_start=self._on_stream_start
380 )
381
382 # Role is created by register_external_player via the factory registry.
383 # Retrieve it and set up audio requirements so the server considers
384 # this client ready for streaming (even though audio chunks are no-ops
385 # since the JS client handles actual audio playback).
386 roles = self._sendspin_client.roles_by_family("player")
387 if roles:
388 self._bridge_role = cast("BridgePlayerRole", roles[0])
389 self._bridge_role.setup_audio_requirements()
390
391 # Register log controller to receive receiver_log messages from the Cast app
392 self._log_controller = SendspinCastController(
393 self.logger,
394 on_fatal_audio_error=self._on_cast_fatal_audio_error,
395 on_cast_connected=self._on_cast_connected,
396 )
397 self.cast_player.cc.register_handler(self._log_controller)
398
399 self._cast_app_was_active = self.cast_player.cc.app_id == SENDSPIN_CAST_APP_ID
400 self.cast_player.on_app_status_changed = self.on_cast_status_changed
401
402 self.logger.info(
403 "Sendspin bridge registered for %s (client_id=%s)",
404 self.cast_player.display_name,
405 self._bridge_client_id,
406 )
407
408 async def stop(self) -> None:
409 """Stop and unregister the Sendspin bridge."""
410 self.cast_player.on_app_status_changed = None
411 self._cast_app_connected = False
412
413 if self._cast_app_ready is not None and not self._cast_app_ready.done():
414 self._cast_app_ready.cancel()
415 self._cast_app_ready = None
416
417 if self._log_controller is not None:
418 self.cast_player.cc.unregister_handler(self._log_controller)
419 self._log_controller = None
420
421 if self._launch_task and not self._launch_task.done():
422 self._launch_task.cancel()
423 with suppress(asyncio.CancelledError):
424 await self._launch_task
425 self._launch_task = None
426
427 if self._sendspin_client:
428 await self.sendspin_server.remove_client(self._bridge_client_id)
429 self._sendspin_client = None
430 self._bridge_role = None
431
432 self.logger.debug("Sendspin bridge stopped for %s", self.cast_player.display_name)
433
434 def on_cast_status_changed(self, app_id: str | None) -> None:
435 """
436 Handle Cast app id change / connection loss (called from socket thread).
437
438 :param app_id: The current Cast app id, or None if the device disconnected.
439 """
440 if app_id == SENDSPIN_CAST_APP_ID:
441 return
442 if not self._cast_app_was_active:
443 return
444 self.mass.loop.call_soon_threadsafe(self._handle_cast_app_gone, app_id)
445
446 async def push_runtime_config_update(self) -> None:
447 """Push updated runtime config to active Cast app."""
448 await self._send_sendspin_config_with_retry()
449
450 def _on_cast_fatal_audio_error(self) -> None:
451 """Handle fatal audio error from Cast receiver (called from socket thread)."""
452 self.mass.loop.call_soon_threadsafe(self.mass.create_task, self._handle_fatal_audio_error())
453
454 async def _handle_fatal_audio_error(self) -> None:
455 """
456 Process fatal audio error on the event loop.
457
458 Removes this device's Sendspin output and records that it cannot run the client,
459 so the device is never offered Sendspin again.
460 """
461 self.logger.error(
462 "Cast device %s does not support AudioContext â audio playback unavailable",
463 self.cast_player.display_name,
464 )
465 # Recorded on the Cast player, not on the bridge: the re-evaluation below takes
466 # the bridge and its config away for good, so this device is never offered again.
467 self.mass.config.set_raw_player_config_value(
468 self.cast_player.player_id, CONF_SENDSPIN_UNSUPPORTED, True
469 )
470 # Bubbles up to the frontend toast via play_media / set_members awaiting this
471 # future. Resolved before the re-evaluation below, which tears this bridge down.
472 self._resolve_cast_app_ready(
473 PlayerCommandFailed(
474 f"Sendspin isn't supported on {self.cast_player.display_name}. "
475 "Use the standard Cast protocol instead."
476 )
477 )
478 await self.provider.bridge_manager.evaluate_bridge(self.cast_player)
479
480 def _on_cast_connected(self) -> None:
481 """Handle Cast app "connected" status (called from socket thread)."""
482 self.mass.loop.call_soon_threadsafe(self._mark_cast_app_connected)
483
484 def _mark_cast_app_connected(self) -> None:
485 """Mark the Cast app as connected and resolve any pending future."""
486 self._cast_app_connected = True
487 self._resolve_cast_app_ready(None)
488
489 def _resolve_cast_app_ready(self, error: BaseException | None) -> None:
490 """
491 Resolve the cast-ready future if still pending.
492
493 :param error: Exception to set on the future, or None for success.
494 """
495 fut = self._cast_app_ready
496 if fut is None or fut.done():
497 return
498 if error is None:
499 fut.set_result(None)
500 else:
501 fut.set_exception(error)
502 fut.exception()
503
504 def _handle_cast_app_gone(self, app_id: str | None) -> None:
505 """Process Cast app disappearance on the event loop."""
506 if not self._cast_app_was_active:
507 return
508 self._cast_app_was_active = False
509 self._cast_app_connected = False
510 self.logger.info(
511 "Sendspin Cast app no longer active on %s (app_id=%s) â detaching client",
512 self.cast_player.display_name,
513 app_id,
514 )
515 client = self._sendspin_client
516 if client is not None:
517 if client.is_connected:
518 client.detach_connection(GoodbyeReason.SHUTDOWN)
519 self.mass.create_task(_apply_fatal_disconnect(client, self.logger))
520 # Bubbles up to the frontend toast via play_media / set_members awaiting this future.
521 self._resolve_cast_app_ready(
522 PlayerCommandFailed(
523 f"Cast app on {self.cast_player.display_name} stopped before reporting ready."
524 )
525 )
526
527 def _on_stream_start(self, request: ExternalStreamStartRequest) -> None:
528 """
529 Handle stream start request from Sendspin server.
530
531 Called when Sendspin wants to play audio to this bridge player.
532 Launches the Sendspin Cast Receiver app on the Chromecast device.
533 The Cast app's JS client will connect to the server with the same
534 client_id, taking over the connection from the bridge.
535 """
536 self.logger.debug(
537 "Sendspin stream start request for %s (reason=%s)",
538 self.cast_player.display_name,
539 request.connection_reason,
540 )
541 self.ensure_cast_app_ready()
542 if not self.cast_player.available:
543 self.logger.warning(
544 "Cannot start Sendspin stream for %s: player not available",
545 self.cast_player.display_name,
546 )
547 return
548 # Cancel any previous launch task
549 if self._launch_task and not self._launch_task.done():
550 self._launch_task.cancel()
551 self._launch_task = self.mass.create_task(self._launch_sendspin_app())
552
553 async def _launch_sendspin_app(self) -> None:
554 """Launch the Sendspin Cast Receiver app and send the server config."""
555 self.cast_player.cancel_pending_app_quit()
556 try:
557 # Launch the Sendspin Cast App on the Chromecast.
558 # force_launch=True ensures the Cast device kills any running app
559 # (including a stale Sendspin session) and starts a fresh instance,
560 # so an explicit quit_app() beforehand is unnecessary.
561 event = asyncio.Event()
562
563 def launched_callback(
564 success: bool, # noqa: ARG001
565 response: dict[str, Any] | None, # noqa: ARG001
566 ) -> None:
567 self.mass.loop.call_soon_threadsafe(event.set)
568
569 def launch() -> None:
570 self.logger.debug(
571 "Launching Sendspin Cast App on %s", self.cast_player.display_name
572 )
573 self.cast_player.cc.socket_client.receiver_controller.launch_app(
574 SENDSPIN_CAST_APP_ID,
575 force_launch=True,
576 callback_function=launched_callback,
577 )
578
579 await self.mass.loop.run_in_executor(None, launch)
580 await asyncio.wait_for(event.wait(), timeout=30.0)
581 # Send config with retry â the Cast app's message listener
582 # may not be ready immediately after the launch callback fires.
583 await self._send_sendspin_config_with_retry()
584 self._cast_app_was_active = True
585
586 self.logger.info(
587 "Sendspin Cast App launched on %s (client_id=%s)",
588 self.cast_player.display_name,
589 self._bridge_client_id,
590 )
591 except TimeoutError:
592 self.logger.warning(
593 "Timed out launching Sendspin Cast App on %s",
594 self.cast_player.display_name,
595 )
596 except Exception as err:
597 self.logger.error(
598 "Failed to launch Sendspin Cast App on %s: %s",
599 self.cast_player.display_name,
600 err,
601 )
602
603 def _get_receiver_log_level(self) -> str:
604 """Map the effective log level to a Cast receiver log level string."""
605 effective = self.logger.getEffectiveLevel()
606 if effective <= logging.DEBUG:
607 return "debug"
608 if effective <= logging.INFO:
609 return "info"
610 if effective <= logging.WARNING:
611 return "warn"
612 if effective <= logging.ERROR:
613 return "error"
614 return "off"
615
616 async def _send_sendspin_config_with_retry(self, max_attempts: int = 3) -> None:
617 """
618 Send the Sendspin config to the Cast app, retrying on failure.
619
620 The Cast app may not have its custom message listener registered
621 immediately after launch. Retry with delays to handle this.
622
623 :param max_attempts: Maximum number of send attempts.
624 """
625 for attempt in range(max_attempts):
626 try:
627 await self._send_sendspin_config()
628 return
629 except Exception as err:
630 if attempt < max_attempts - 1:
631 self.logger.debug(
632 "Config send attempt %d/%d failed for %s: %s, retrying...",
633 attempt + 1,
634 max_attempts,
635 self.cast_player.display_name,
636 err,
637 )
638 await asyncio.sleep(2)
639 else:
640 self.logger.warning(
641 "Failed to send config to Cast app on %s after %d attempts: %s",
642 self.cast_player.display_name,
643 max_attempts,
644 err,
645 )
646
647 async def _send_sendspin_config(self) -> None:
648 """
649 Send the server URL, client_id, and settings to the Sendspin Cast app.
650
651 The Cast app uses this info to connect its JS Sendspin client
652 back to the server with the same client_id.
653 """
654 # The Sendspin server runs on its own port, NOT through
655 # the MA webserver or streams server. Use publish_ip directly.
656 publish_ip = self.mass.streams.publish_ip
657 # sendspin-js's SendspinCore appends `/sendspin` to baseUrl when constructing
658 # the WebSocket URL. Send the bare server URL here so it ends up correct.
659 server_url = f"ws://{format_ip_for_url(publish_ip)}:{SENDSPIN_SERVER_PORT}"
660 raw_delay = self.mass.config.get_raw_player_config_value(
661 self._bridge_client_id, CONF_SENDSPIN_STATIC_DELAY
662 )
663 if raw_delay is None:
664 sync_delay = get_cast_model_static_delay(
665 self.cast_player.device_info.manufacturer or "",
666 self.cast_player.device_info.model or "",
667 )
668 else:
669 sync_delay = int(cast("int", raw_delay))
670 # The Cast receiver JS reads playerId (not clientId) from the config.
671 # It uses this as the client_id in its hello message to the Sendspin server,
672 # allowing the server to match it to the bridge's pre-registered external client.
673 message = {
674 "type": "config",
675 "serverUrl": server_url,
676 "playerId": self._bridge_client_id,
677 "playerName": f"{self.cast_player.display_name} (Cast)",
678 "syncDelay": sync_delay,
679 "codecs": ["flac"],
680 "receiverLogLevel": self._get_receiver_log_level(),
681 }
682
683 def send() -> None:
684 self.cast_player.cc.socket_client.send_app_message(SENDSPIN_CAST_NAMESPACE, message)
685
686 await self.mass.loop.run_in_executor(None, send)
687 self.logger.debug(
688 "Sent Sendspin config to Cast app on %s: serverUrl=%s, playerId=%s, syncDelay=%dms",
689 self.cast_player.display_name,
690 message["serverUrl"],
691 self._bridge_client_id,
692 sync_delay,
693 )
694
695
696class SendspinBridgeManager(SendspinBridgeManagerBase[SendspinChromecastBridge]):
697 """Manages Sendspin bridges for all Chromecast players."""
698
699 def __init__(self, provider: ChromecastProvider) -> None:
700 """
701 Initialize the bridge manager.
702
703 :param provider: The Chromecast provider instance.
704 """
705 super().__init__(provider)
706 self._rebridge_unsubs: dict[str, Callable[[], None]] = {}
707 self._claimed_clients: dict[str, str] = {}
708 self._blocklist_log_state: dict[str, tuple[str, str]] = {}
709 self._pending_bridge_evaluations: set[str] = set()
710 self._unsubs.append(
711 self.mass.players.subscribe_player_state_update(self._on_player_state_updated)
712 )
713 self._unsubs.append(
714 self.mass.subscribe(
715 self._on_player_unregistered,
716 (EventType.PLAYER_UPDATED, EventType.PLAYER_REMOVED),
717 )
718 )
719
720 async def evaluate_bridge(self, player: Player) -> None:
721 """
722 Reconcile the Sendspin bridge state for a Chromecast player.
723
724 :param player: The player to evaluate.
725 """
726 client_id = self._bridge_client_id(player)
727 if (
728 client_id
729 and not self.mass.config.get(f"{CONF_PLAYERS}/{client_id}")
730 and self.mass.config.get(f"{CONF_PLAYERS}/{player.player_id}")
731 and self._should_have_bridge(player)
732 ):
733 # This device was never offered the bridge, so its output has to end up off.
734 # Recorded before the bridge registers and before anything can go wrong with
735 # applying it, because registration is what creates the config this reads.
736 self.mass.config.set_raw_player_config_value(
737 player.player_id, CONF_SENDSPIN_OPT_OUT_PENDING, True
738 )
739 await super().evaluate_bridge(player)
740 if not client_id or not self._has_bridge(player.player_id):
741 return
742 claimed_id = self._claimed_clients.get(player.player_id)
743 if claimed_id is not None and claimed_id != client_id:
744 # A client claimed under the other MAC variant belongs to the device's AirPlay
745 # bridge, not to its Cast output, so its warning and opt-out are not ours.
746 return
747 if self.mass.config.get_raw_player_config_value(client_id, CONF_PROTOCOL_EXPERIMENTAL_NOTE):
748 # the note is written last, so its presence means this output is settled
749 return
750 never_offered = bool(
751 self.mass.config.get_raw_player_config_value(
752 player.player_id, CONF_SENDSPIN_OPT_OUT_PENDING
753 )
754 )
755 self.mass.create_task(
756 self._flag_bridge_experimental(player.player_id, client_id, disable=never_offered),
757 task_id=f"sendspin_cast_experimental_{client_id}",
758 )
759
760 async def remove_bridge(self, player_id: str, permanent: bool = False) -> None:
761 """
762 Remove the Sendspin bridge (or claimed client) for a Chromecast player.
763
764 :param player_id: The player ID to remove the bridge for.
765 :param permanent: Also remove the bridge client player and its config.
766 """
767 claimed_client_id = self._claimed_clients.pop(player_id, None)
768 if claimed_client_id:
769 if unsub := self._rebridge_unsubs.pop(claimed_client_id, None):
770 with suppress(Exception):
771 unsub()
772 if sendspin_server := self.sendspin_server:
773 with suppress(Exception):
774 await sendspin_server.remove_client(claimed_client_id)
775 if permanent:
776 if self.mass.players.get_player(claimed_client_id):
777 await self.mass.players.unregister(claimed_client_id, permanent=True)
778 else:
779 self.mass.players.delete_player_config(claimed_client_id)
780 self.logger.debug("Claimed Sendspin client removed for Chromecast player %s", player_id)
781 await super().remove_bridge(player_id, permanent=permanent)
782
783 async def close(self) -> None:
784 """Stop all bridges and unsubscribe event listeners."""
785 for unsub in list(self._rebridge_unsubs.values()):
786 with suppress(Exception):
787 unsub()
788 self._rebridge_unsubs.clear()
789 self._claimed_clients.clear()
790 self._blocklist_log_state.clear()
791 self._pending_bridge_evaluations.clear()
792 await super().close()
793
794 def get_bridge_by_client_id(self, bridge_client_id: str) -> SendspinChromecastBridge | None:
795 """
796 Return the bridge whose `bridge_client_id` matches, if any.
797
798 :param bridge_client_id: The Sendspin client_id used by a bridged Cast device.
799 """
800 for bridge in self._bridges.values():
801 if bridge.bridge_client_id == bridge_client_id:
802 return bridge
803 return None
804
805 def _bridge_client_id(self, player: Player) -> str | None:
806 """Return the Sendspin client_id used to bridge a Chromecast player."""
807 return get_bridge_client_id(cast("ChromecastPlayer", player))
808
809 def _create_bridge(self, player: Player) -> SendspinChromecastBridge:
810 """Create a bridge instance for a Chromecast player."""
811 cast_player = cast("ChromecastPlayer", player)
812 bridge_client_id = get_bridge_client_id(cast_player)
813 sendspin_server = self.sendspin_server
814 # both guaranteed by _should_have_bridge/_lifecycle_allows_bridge
815 assert bridge_client_id is not None
816 assert sendspin_server is not None
817 return SendspinChromecastBridge(
818 cast("ChromecastProvider", self.provider),
819 cast_player,
820 sendspin_server,
821 bridge_client_id,
822 )
823
824 def _has_bridge(self, player_id: str) -> bool:
825 """Return whether a bridge or claimed client is in place for the player."""
826 return player_id in self._bridges or player_id in self._claimed_clients
827
828 def _should_have_bridge(self, player: Player) -> bool:
829 """
830 Return whether this cast player should have a Sendspin bridge.
831
832 :param player: The Chromecast player to evaluate.
833 """
834 cast_player = cast("ChromecastPlayer", player)
835 # Audio groups (non-stereo-pair) have their own playback mechanism
836 if cast_player.cast_info.is_audio_group and not cast_player.cast_info.is_multichannel_group:
837 return False
838
839 if not get_bridge_client_id(cast_player):
840 return False
841
842 if self.mass.config.get_raw_player_config_value(
843 cast_player.player_id, CONF_SENDSPIN_UNSUPPORTED
844 ):
845 # the receiver told us it cannot run the Sendspin client, so do not offer it
846 return False
847
848 manufacturer = cast_player.device_info.manufacturer or ""
849 model = cast_player.device_info.model or ""
850 if is_sendspin_cast_blocked(manufacturer, model):
851 blocklist_state = (manufacturer, model)
852 if self._blocklist_log_state.get(cast_player.player_id) != blocklist_state:
853 self.logger.debug(
854 "Skipping Sendspin Cast bridge for %s (%s / %s) â device is blocklisted",
855 cast_player.display_name,
856 manufacturer,
857 model,
858 )
859 self._blocklist_log_state[cast_player.player_id] = blocklist_state
860 return False
861 self._blocklist_log_state.pop(cast_player.player_id, None)
862
863 # Prefer the airplay bridge over the cast bridge: the Cast receiver
864 # pipeline is fragile on many (especially 3rd-party) devices. Hard-deny
865 # the cast bridge whenever the device is known to also support AirPlay,
866 # regardless of whether the airplay protocol/provider is currently
867 # enabled - a device with AirPlay gets its Sendspin support over
868 # AirPlay or not at all.
869 if cast_player.protocol_parent_id:
870 parent_player = self.mass.players.get_player(cast_player.protocol_parent_id)
871 if parent_player and parent_player.get_output_protocol_by_domain("airplay"):
872 return False
873
874 return True
875
876 async def _try_claim_existing(self, player: Player) -> bool:
877 """
878 Claim a Sendspin client that connected on its own (JS Cast receiver).
879
880 Happens on MA restart when the JS Cast receiver reconnects to the
881 Sendspin server before Chromecast discovery fires.
882
883 :param player: The Chromecast player being bridged.
884 :return: True when an existing client was claimed (skips bridge setup).
885 """
886 cast_player = cast("ChromecastPlayer", player)
887 bridge_client_id = get_bridge_client_id(cast_player)
888 sendspin_server = self.sendspin_server
889 sendspin_provider = self.sendspin_provider
890 if bridge_client_id is None or sendspin_server is None or sendspin_provider is None:
891 return False
892 existing_client_id = self._find_existing_sendspin_client(
893 sendspin_server, bridge_client_id, cast_player
894 )
895 if not existing_client_id:
896 return False
897
898 self.logger.info(
899 "Sendspin client %s already registered â claiming existing player for %s",
900 existing_client_id,
901 cast_player.display_name,
902 )
903 manufacturer = cast_player.device_info.manufacturer or ""
904 model = cast_player.device_info.model or ""
905 bridge_hello = _build_bridge_hello(cast_player, existing_client_id)
906 identifiers = {IdentifierType.CAST_UUID: str(cast_player.cast_info.uuid)}
907 sendspin_provider.register_bridge_static_delay_default(
908 existing_client_id, get_cast_model_static_delay(manufacturer, model)
909 )
910 if not await sendspin_provider.apply_bridge_claim(
911 existing_client_id,
912 identifiers,
913 bridge_hello,
914 underlying_player_id=cast_player.player_id,
915 ):
916 # SendspinPlayer registration still in flight
917 # (`_handle_client_added` waits up to 5 s for hello).
918 # Pre-register identifiers so `_create_player` attaches
919 # CAST_UUID when it runs.
920 sendspin_provider.register_bridge_identifiers(existing_client_id, identifiers)
921 sendspin_provider.register_bridge_underlying_player(
922 existing_client_id, cast_player.player_id
923 )
924 self._claimed_clients[cast_player.player_id] = existing_client_id
925 self._subscribe_rebridge_on_disconnect(cast_player, existing_client_id)
926 return True
927
928 def _find_existing_sendspin_client(
929 self,
930 sendspin_server: SendspinServer,
931 bridge_client_id: str,
932 cast_player: ChromecastPlayer,
933 ) -> str | None:
934 """
935 Return a matching already-registered Sendspin client_id, if any.
936
937 Happens on MA restart when the JS Cast receiver reconnects to the
938 Sendspin server before Chromecast discovery fires. Also checks the
939 LA-bit MAC variant (AirPlay uses locally-administered MAC, Chromecast
940 uses the real one).
941 """
942 if sendspin_server.get_client(bridge_client_id):
943 return bridge_client_id
944 if cast_player.cast_info.is_audio_group:
945 return None
946 la_variant_mac = _toggle_locally_administered_bit(bridge_client_id[len(BRIDGE_PREFIX) :])
947 if not la_variant_mac:
948 return None
949 la_variant_id = f"{BRIDGE_PREFIX}{la_variant_mac}"
950 if sendspin_server.get_client(la_variant_id):
951 return la_variant_id
952 return None
953
954 def _subscribe_rebridge_on_disconnect(
955 self, cast_player: ChromecastPlayer, client_id: str
956 ) -> None:
957 """
958 Subscribe a one-shot listener that re-runs setup_bridge on client disconnect.
959
960 After claiming an already-registered external client (JS Cast receiver),
961 the bridge's on_stream_start launch handler is not wired. When the JS
962 client eventually disconnects (idle exit, device reboot), re-run
963 setup_bridge so the fresh-client path registers the external player with
964 a proper launch handler.
965 """
966 sendspin_provider = self.sendspin_provider
967 if sendspin_provider is None:
968 return
969 if existing := self._rebridge_unsubs.pop(client_id, None):
970 with suppress(Exception):
971 existing()
972 server_api = sendspin_provider.server_api
973
974 def _listener(_server: SendspinServer, event: SendspinEvent) -> None:
975 if not isinstance(event, ClientRemovedEvent):
976 return
977 if event.client_id != client_id:
978 return
979 unsub = self._rebridge_unsubs.pop(client_id, None)
980 if unsub is not None:
981 with suppress(Exception):
982 unsub()
983 self._claimed_clients.pop(cast_player.player_id, None)
984 if self.mass.players.get_player(cast_player.player_id) is not cast_player:
985 return
986 if cast_player.player_id in self._bridges:
987 return
988 self.mass.create_task(self.setup_bridge(cast_player))
989
990 event_unsub = server_api.add_event_listener(_listener)
991
992 cast_app_was_active = cast_player.cc.app_id == SENDSPIN_CAST_APP_ID
993
994 def _handle_app_gone(app_id: str | None) -> None:
995 nonlocal cast_app_was_active
996 if not cast_app_was_active:
997 return
998 cast_app_was_active = False
999 self.logger.info(
1000 "Claimed Sendspin Cast client lost on %s (app_id=%s) â detaching",
1001 cast_player.display_name,
1002 app_id,
1003 )
1004 client = server_api.get_client(client_id)
1005 if client is not None:
1006 if client.is_connected:
1007 client.detach_connection(GoodbyeReason.SHUTDOWN)
1008 self.mass.create_task(_apply_fatal_disconnect(client, self.logger))
1009 if cast_player.on_app_status_changed is _on_cast_status_changed:
1010 cast_player.on_app_status_changed = None
1011
1012 def _on_cast_status_changed(app_id: str | None) -> None:
1013 if app_id == SENDSPIN_CAST_APP_ID:
1014 return
1015 if not cast_app_was_active:
1016 return
1017 self.mass.loop.call_soon_threadsafe(_handle_app_gone, app_id)
1018
1019 cast_player.on_app_status_changed = _on_cast_status_changed
1020
1021 def _combined_unsub() -> None:
1022 with suppress(Exception):
1023 event_unsub()
1024 if cast_player.on_app_status_changed is _on_cast_status_changed:
1025 cast_player.on_app_status_changed = None
1026
1027 self._rebridge_unsubs[client_id] = _combined_unsub
1028
1029 def _on_player_state_updated(
1030 self, updated_player: Player, changed_values: dict[str, tuple[Any, Any]]
1031 ) -> None:
1032 """Re-evaluate bridge state after a relevant player state change."""
1033 if not changed_values.keys() & _BRIDGE_POLICY_STATE_FIELDS:
1034 return
1035 match_ids = {updated_player.player_id}
1036 if updated_player.protocol_parent_id:
1037 match_ids.add(updated_player.protocol_parent_id)
1038 self._schedule_bridge_evaluations(match_ids)
1039
1040 def _on_player_unregistered(self, event: MassEvent) -> None:
1041 """Re-evaluate Cast bridges after a possible parent is unregistered."""
1042 if not event.object_id or self.mass.players.get_player(event.object_id):
1043 return
1044 self._schedule_bridge_evaluations({event.object_id})
1045
1046 def _schedule_bridge_evaluations(self, match_ids: set[str]) -> None:
1047 """Schedule reconciliation for Cast bridges matching a player ID."""
1048 for player in self.provider.players:
1049 cast_player = cast("ChromecastPlayer", player)
1050 if cast_player.player_id in match_ids or (
1051 cast_player.protocol_parent_id and cast_player.protocol_parent_id in match_ids
1052 ):
1053 self._pending_bridge_evaluations.add(cast_player.player_id)
1054 self.mass.create_task(
1055 self._process_pending_bridge_evaluations,
1056 cast_player.player_id,
1057 task_id=f"evaluate_chromecast_sendspin_bridge_{cast_player.player_id}",
1058 )
1059
1060 async def _process_pending_bridge_evaluations(self, player_id: str) -> None:
1061 """
1062 Reconcile all bridge policy updates queued for a Chromecast player.
1063
1064 :param player_id: The Chromecast player ID to reconcile.
1065 """
1066 while player_id in self._pending_bridge_evaluations:
1067 self._pending_bridge_evaluations.discard(player_id)
1068 player = self.mass.players.get_player(player_id)
1069 if player is None or player.provider is not self.provider:
1070 return
1071 await self.evaluate_bridge(player)
1072
1073 async def _on_player_config_updated(self, event: MassEvent) -> None:
1074 """Re-evaluate bridges and push runtime config updates to active Cast apps."""
1075 await super()._on_player_config_updated(event)
1076 if not event.object_id:
1077 return
1078
1079 bridge: SendspinChromecastBridge | None = None
1080 async with self._lock:
1081 for candidate in self._bridges.values():
1082 if candidate.bridge_client_id == event.object_id:
1083 bridge = candidate
1084 break
1085
1086 if not bridge:
1087 return
1088
1089 if not bridge.is_cast_app_active:
1090 return
1091
1092 self.mass.create_task(
1093 bridge.push_runtime_config_update,
1094 task_id=f"chromecast_sendspin_config_update_{bridge.cast_player.player_id}",
1095 abort_existing=True,
1096 )
1097
1098 async def _flag_bridge_experimental(
1099 self, player_id: str, client_id: str, disable: bool
1100 ) -> None:
1101 """
1102 Mark a bridged Cast device's Sendspin output as experimental, and opt it out.
1103
1104 :param player_id: The Chromecast player the bridge belongs to.
1105 :param client_id: The Sendspin client_id of the bridge.
1106 :param disable: Switch the output off, leaving it for the user to opt in.
1107 """
1108 # The output's toggle and warning are built from the persisted protocol link, so
1109 # a bridge that is about to be switched off again has to be linked first or it
1110 # leaves nothing behind to opt in with.
1111 if not await self._wait_for_protocol_link(player_id, client_id):
1112 return
1113 if not self._has_bridge(player_id):
1114 # the bridge went away while we waited - its config may be gone with it
1115 return
1116 if disable:
1117 self.logger.info(
1118 "Sendspin over Cast is experimental: leaving it switched off for %s "
1119 "until it is enabled in the player settings",
1120 client_id,
1121 )
1122 await self.mass.config.save_player_config(client_id, {CONF_ENABLED: False})
1123 # written last: a missing note is what makes the next evaluation retry all of this
1124 self.mass.config.set_raw_player_config_value(
1125 client_id, CONF_PROTOCOL_EXPERIMENTAL_NOTE, SENDSPIN_CAST_EXPERIMENTAL_NOTE
1126 )
1127 self.mass.config.set_raw_player_config_value(player_id, CONF_SENDSPIN_OPT_OUT_PENDING, None)
1128
1129 async def _wait_for_protocol_link(self, player_id: str, client_id: str) -> bool:
1130 """
1131 Wait for a bridge client's protocol link to be persisted.
1132
1133 :param player_id: The Chromecast player the bridge belongs to.
1134 :param client_id: The Sendspin client_id of the bridge.
1135 :return: True once the link is stored, False when it did not appear in time.
1136 """
1137 for _ in range(SENDSPIN_LINK_WAIT_TRIES):
1138 if not self._has_bridge(player_id):
1139 # the bridge was taken away again, e.g. because the device turned out to
1140 # also speak AirPlay, so there is no output left to settle
1141 return False
1142 parent_id = self.mass.config.get_raw_player_config_value(
1143 client_id, CONF_PROTOCOL_PARENT_ID
1144 )
1145 if parent_id and self.mass.config.get(f"{CONF_PLAYERS}/{parent_id}"):
1146 return True
1147 await asyncio.sleep(SENDSPIN_LINK_WAIT_INTERVAL)
1148 self.logger.debug(
1149 "Sendspin bridge %s was not linked to a parent player in time; "
1150 "its output is left as it is and retried on the next evaluation",
1151 client_id,
1152 )
1153 return False
1154