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