/
/
/
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 try:
537 # Launch the Sendspin Cast App on the Chromecast.
538 # force_launch=True ensures the Cast device kills any running app
539 # (including a stale Sendspin session) and starts a fresh instance,
540 # so an explicit quit_app() beforehand is unnecessary.
541 event = asyncio.Event()
542
543 def launched_callback(
544 success: bool, # noqa: ARG001
545 response: dict[str, Any] | None, # noqa: ARG001
546 ) -> None:
547 self.mass.loop.call_soon_threadsafe(event.set)
548
549 def launch() -> None:
550 self.logger.debug(
551 "Launching Sendspin Cast App on %s", self.cast_player.display_name
552 )
553 self.cast_player.cc.socket_client.receiver_controller.launch_app(
554 SENDSPIN_CAST_APP_ID,
555 force_launch=True,
556 callback_function=launched_callback,
557 )
558
559 await self.mass.loop.run_in_executor(None, launch)
560 await asyncio.wait_for(event.wait(), timeout=30.0)
561 # Send config with retry â the Cast app's message listener
562 # may not be ready immediately after the launch callback fires.
563 await self._send_sendspin_config_with_retry()
564 self._cast_app_was_active = True
565
566 self.logger.info(
567 "Sendspin Cast App launched on %s (client_id=%s)",
568 self.cast_player.display_name,
569 self._bridge_client_id,
570 )
571 except TimeoutError:
572 self.logger.warning(
573 "Timed out launching Sendspin Cast App on %s",
574 self.cast_player.display_name,
575 )
576 except Exception as err:
577 self.logger.error(
578 "Failed to launch Sendspin Cast App on %s: %s",
579 self.cast_player.display_name,
580 err,
581 )
582
583 def _get_receiver_log_level(self) -> str:
584 """Map the effective log level to a Cast receiver log level string."""
585 effective = self.logger.getEffectiveLevel()
586 if effective <= logging.DEBUG:
587 return "debug"
588 if effective <= logging.INFO:
589 return "info"
590 if effective <= logging.WARNING:
591 return "warn"
592 if effective <= logging.ERROR:
593 return "error"
594 return "off"
595
596 async def _send_sendspin_config_with_retry(self, max_attempts: int = 3) -> None:
597 """
598 Send the Sendspin config to the Cast app, retrying on failure.
599
600 The Cast app may not have its custom message listener registered
601 immediately after launch. Retry with delays to handle this.
602
603 :param max_attempts: Maximum number of send attempts.
604 """
605 for attempt in range(max_attempts):
606 try:
607 await self._send_sendspin_config()
608 return
609 except Exception as err:
610 if attempt < max_attempts - 1:
611 self.logger.debug(
612 "Config send attempt %d/%d failed for %s: %s, retrying...",
613 attempt + 1,
614 max_attempts,
615 self.cast_player.display_name,
616 err,
617 )
618 await asyncio.sleep(2)
619 else:
620 self.logger.warning(
621 "Failed to send config to Cast app on %s after %d attempts: %s",
622 self.cast_player.display_name,
623 max_attempts,
624 err,
625 )
626
627 async def _send_sendspin_config(self) -> None:
628 """
629 Send the server URL, client_id, and settings to the Sendspin Cast app.
630
631 The Cast app uses this info to connect its JS Sendspin client
632 back to the server with the same client_id.
633 """
634 # The Sendspin server runs on its own port, NOT through
635 # the MA webserver or streams server. Use publish_ip directly.
636 publish_ip = self.mass.streams.publish_ip
637 # sendspin-js's SendspinCore appends `/sendspin` to baseUrl when constructing
638 # the WebSocket URL. Send the bare server URL here so it ends up correct.
639 server_url = f"ws://{format_ip_for_url(publish_ip)}:{SENDSPIN_SERVER_PORT}"
640 raw_delay = self.mass.config.get_raw_player_config_value(
641 self._bridge_client_id, CONF_SENDSPIN_STATIC_DELAY
642 )
643 if raw_delay is None:
644 sync_delay = get_cast_model_static_delay(
645 self.cast_player.device_info.manufacturer or "",
646 self.cast_player.device_info.model or "",
647 )
648 else:
649 sync_delay = int(cast("int", raw_delay))
650 # The Cast receiver JS reads playerId (not clientId) from the config.
651 # It uses this as the client_id in its hello message to the Sendspin server,
652 # allowing the server to match it to the bridge's pre-registered external client.
653 message = {
654 "type": "config",
655 "serverUrl": server_url,
656 "playerId": self._bridge_client_id,
657 "playerName": f"{self.cast_player.display_name} (Cast)",
658 "syncDelay": sync_delay,
659 "codecs": ["flac"],
660 "receiverLogLevel": self._get_receiver_log_level(),
661 }
662
663 def send() -> None:
664 self.cast_player.cc.socket_client.send_app_message(SENDSPIN_CAST_NAMESPACE, message)
665
666 await self.mass.loop.run_in_executor(None, send)
667 self.logger.debug(
668 "Sent Sendspin config to Cast app on %s: serverUrl=%s, playerId=%s, syncDelay=%dms",
669 self.cast_player.display_name,
670 message["serverUrl"],
671 self._bridge_client_id,
672 sync_delay,
673 )
674
675
676class SendspinBridgeManager(SendspinBridgeManagerBase[SendspinChromecastBridge]):
677 """Manages Sendspin bridges for all Chromecast players."""
678
679 def __init__(self, provider: ChromecastProvider) -> None:
680 """
681 Initialize the bridge manager.
682
683 :param provider: The Chromecast provider instance.
684 """
685 super().__init__(provider)
686 self._rebridge_unsubs: dict[str, Callable[[], None]] = {}
687 self._claimed_clients: dict[str, str] = {}
688 self._blocklist_log_state: dict[str, tuple[str, str]] = {}
689 self._pending_bridge_evaluations: set[str] = set()
690 self._unsubs.append(
691 self.mass.players.subscribe_player_state_update(self._on_player_state_updated)
692 )
693 self._unsubs.append(
694 self.mass.subscribe(
695 self._on_player_unregistered,
696 (EventType.PLAYER_UPDATED, EventType.PLAYER_REMOVED),
697 )
698 )
699
700 async def remove_bridge(self, player_id: str, permanent: bool = False) -> None:
701 """
702 Remove the Sendspin bridge (or claimed client) for a Chromecast player.
703
704 :param player_id: The player ID to remove the bridge for.
705 :param permanent: Also remove the bridge client player and its config.
706 """
707 claimed_client_id = self._claimed_clients.pop(player_id, None)
708 if claimed_client_id:
709 if unsub := self._rebridge_unsubs.pop(claimed_client_id, None):
710 with suppress(Exception):
711 unsub()
712 if sendspin_server := self.sendspin_server:
713 with suppress(Exception):
714 await sendspin_server.remove_client(claimed_client_id)
715 if permanent:
716 if self.mass.players.get_player(claimed_client_id):
717 await self.mass.players.unregister(claimed_client_id, permanent=True)
718 else:
719 self.mass.players.delete_player_config(claimed_client_id)
720 self.logger.debug("Claimed Sendspin client removed for Chromecast player %s", player_id)
721 await super().remove_bridge(player_id, permanent=permanent)
722
723 async def close(self) -> None:
724 """Stop all bridges and unsubscribe event listeners."""
725 for unsub in list(self._rebridge_unsubs.values()):
726 with suppress(Exception):
727 unsub()
728 self._rebridge_unsubs.clear()
729 self._claimed_clients.clear()
730 self._blocklist_log_state.clear()
731 self._pending_bridge_evaluations.clear()
732 await super().close()
733
734 def get_bridge_by_client_id(self, bridge_client_id: str) -> SendspinChromecastBridge | None:
735 """
736 Return the bridge whose `bridge_client_id` matches, if any.
737
738 :param bridge_client_id: The Sendspin client_id used by a bridged Cast device.
739 """
740 for bridge in self._bridges.values():
741 if bridge.bridge_client_id == bridge_client_id:
742 return bridge
743 return None
744
745 def _bridge_client_id(self, player: Player) -> str | None:
746 """Return the Sendspin client_id used to bridge a Chromecast player."""
747 return get_bridge_client_id(cast("ChromecastPlayer", player))
748
749 def _create_bridge(self, player: Player) -> SendspinChromecastBridge:
750 """Create a bridge instance for a Chromecast player."""
751 cast_player = cast("ChromecastPlayer", player)
752 bridge_client_id = get_bridge_client_id(cast_player)
753 sendspin_server = self.sendspin_server
754 # both guaranteed by _should_have_bridge/_lifecycle_allows_bridge
755 assert bridge_client_id is not None
756 assert sendspin_server is not None
757 return SendspinChromecastBridge(
758 cast("ChromecastProvider", self.provider),
759 cast_player,
760 sendspin_server,
761 bridge_client_id,
762 )
763
764 def _has_bridge(self, player_id: str) -> bool:
765 """Return whether a bridge or claimed client is in place for the player."""
766 return player_id in self._bridges or player_id in self._claimed_clients
767
768 def _should_have_bridge(self, player: Player) -> bool:
769 """
770 Return whether this cast player should have a Sendspin bridge.
771
772 :param player: The Chromecast player to evaluate.
773 """
774 cast_player = cast("ChromecastPlayer", player)
775 # Audio groups (non-stereo-pair) have their own playback mechanism
776 if cast_player.cast_info.is_audio_group and not cast_player.cast_info.is_multichannel_group:
777 return False
778
779 if not get_bridge_client_id(cast_player):
780 return False
781
782 manufacturer = cast_player.device_info.manufacturer or ""
783 model = cast_player.device_info.model or ""
784 if is_sendspin_cast_blocked(manufacturer, model):
785 blocklist_state = (manufacturer, model)
786 if self._blocklist_log_state.get(cast_player.player_id) != blocklist_state:
787 self.logger.debug(
788 "Skipping Sendspin Cast bridge for %s (%s / %s) â device is blocklisted",
789 cast_player.display_name,
790 manufacturer,
791 model,
792 )
793 self._blocklist_log_state[cast_player.player_id] = blocklist_state
794 return False
795 self._blocklist_log_state.pop(cast_player.player_id, None)
796
797 # Prefer the airplay bridge over the cast bridge: the Cast receiver
798 # pipeline is fragile on many (especially 3rd-party) devices. Hard-deny
799 # the cast bridge whenever the device is known to also support AirPlay,
800 # regardless of whether the airplay protocol/provider is currently
801 # enabled - a device with AirPlay gets its Sendspin support over
802 # AirPlay or not at all.
803 if cast_player.protocol_parent_id:
804 parent_player = self.mass.players.get_player(cast_player.protocol_parent_id)
805 if parent_player and parent_player.get_output_protocol_by_domain("airplay"):
806 return False
807
808 return True
809
810 async def _try_claim_existing(self, player: Player) -> bool:
811 """
812 Claim a Sendspin client that connected on its own (JS Cast receiver).
813
814 Happens on MA restart when the JS Cast receiver reconnects to the
815 Sendspin server before Chromecast discovery fires.
816
817 :param player: The Chromecast player being bridged.
818 :return: True when an existing client was claimed (skips bridge setup).
819 """
820 cast_player = cast("ChromecastPlayer", player)
821 bridge_client_id = get_bridge_client_id(cast_player)
822 sendspin_server = self.sendspin_server
823 sendspin_provider = self.sendspin_provider
824 if bridge_client_id is None or sendspin_server is None or sendspin_provider is None:
825 return False
826 existing_client_id = self._find_existing_sendspin_client(
827 sendspin_server, bridge_client_id, cast_player
828 )
829 if not existing_client_id:
830 return False
831
832 self.logger.info(
833 "Sendspin client %s already registered â claiming existing player for %s",
834 existing_client_id,
835 cast_player.display_name,
836 )
837 manufacturer = cast_player.device_info.manufacturer or ""
838 model = cast_player.device_info.model or ""
839 bridge_hello = _build_bridge_hello(cast_player, existing_client_id)
840 identifiers = {IdentifierType.CAST_UUID: str(cast_player.cast_info.uuid)}
841 sendspin_provider.register_bridge_static_delay_default(
842 existing_client_id, get_cast_model_static_delay(manufacturer, model)
843 )
844 if not await sendspin_provider.apply_bridge_claim(
845 existing_client_id,
846 identifiers,
847 bridge_hello,
848 underlying_player_id=cast_player.player_id,
849 ):
850 # SendspinPlayer registration still in flight
851 # (`_handle_client_added` waits up to 5 s for hello).
852 # Pre-register identifiers so `_create_player` attaches
853 # CAST_UUID when it runs.
854 sendspin_provider.register_bridge_identifiers(existing_client_id, identifiers)
855 sendspin_provider.register_bridge_underlying_player(
856 existing_client_id, cast_player.player_id
857 )
858 self._claimed_clients[cast_player.player_id] = existing_client_id
859 self._subscribe_rebridge_on_disconnect(cast_player, existing_client_id)
860 return True
861
862 def _find_existing_sendspin_client(
863 self,
864 sendspin_server: SendspinServer,
865 bridge_client_id: str,
866 cast_player: ChromecastPlayer,
867 ) -> str | None:
868 """
869 Return a matching already-registered Sendspin client_id, if any.
870
871 Happens on MA restart when the JS Cast receiver reconnects to the
872 Sendspin server before Chromecast discovery fires. Also checks the
873 LA-bit MAC variant (AirPlay uses locally-administered MAC, Chromecast
874 uses the real one).
875 """
876 if sendspin_server.get_client(bridge_client_id):
877 return bridge_client_id
878 if cast_player.cast_info.is_audio_group:
879 return None
880 la_variant_mac = _toggle_locally_administered_bit(bridge_client_id[len(BRIDGE_PREFIX) :])
881 if not la_variant_mac:
882 return None
883 la_variant_id = f"{BRIDGE_PREFIX}{la_variant_mac}"
884 if sendspin_server.get_client(la_variant_id):
885 return la_variant_id
886 return None
887
888 def _subscribe_rebridge_on_disconnect(
889 self, cast_player: ChromecastPlayer, client_id: str
890 ) -> None:
891 """
892 Subscribe a one-shot listener that re-runs setup_bridge on client disconnect.
893
894 After claiming an already-registered external client (JS Cast receiver),
895 the bridge's on_stream_start launch handler is not wired. When the JS
896 client eventually disconnects (idle exit, device reboot), re-run
897 setup_bridge so the fresh-client path registers the external player with
898 a proper launch handler.
899 """
900 sendspin_provider = self.sendspin_provider
901 if sendspin_provider is None:
902 return
903 if existing := self._rebridge_unsubs.pop(client_id, None):
904 with suppress(Exception):
905 existing()
906 server_api = sendspin_provider.server_api
907
908 def _listener(_server: SendspinServer, event: SendspinEvent) -> None:
909 if not isinstance(event, ClientRemovedEvent):
910 return
911 if event.client_id != client_id:
912 return
913 unsub = self._rebridge_unsubs.pop(client_id, None)
914 if unsub is not None:
915 with suppress(Exception):
916 unsub()
917 self._claimed_clients.pop(cast_player.player_id, None)
918 if self.mass.players.get_player(cast_player.player_id) is not cast_player:
919 return
920 if cast_player.player_id in self._bridges:
921 return
922 self.mass.create_task(self.setup_bridge(cast_player))
923
924 event_unsub = server_api.add_event_listener(_listener)
925
926 cast_app_was_active = cast_player.cc.app_id == SENDSPIN_CAST_APP_ID
927
928 def _handle_app_gone(app_id: str | None) -> None:
929 nonlocal cast_app_was_active
930 if not cast_app_was_active:
931 return
932 cast_app_was_active = False
933 self.logger.info(
934 "Claimed Sendspin Cast client lost on %s (app_id=%s) â detaching",
935 cast_player.display_name,
936 app_id,
937 )
938 client = server_api.get_client(client_id)
939 if client is not None:
940 if client.is_connected:
941 client.detach_connection(GoodbyeReason.SHUTDOWN)
942 self.mass.create_task(_apply_fatal_disconnect(client, self.logger))
943 if cast_player.on_app_status_changed is _on_cast_status_changed:
944 cast_player.on_app_status_changed = None
945
946 def _on_cast_status_changed(app_id: str | None) -> None:
947 if app_id == SENDSPIN_CAST_APP_ID:
948 return
949 if not cast_app_was_active:
950 return
951 self.mass.loop.call_soon_threadsafe(_handle_app_gone, app_id)
952
953 cast_player.on_app_status_changed = _on_cast_status_changed
954
955 def _combined_unsub() -> None:
956 with suppress(Exception):
957 event_unsub()
958 if cast_player.on_app_status_changed is _on_cast_status_changed:
959 cast_player.on_app_status_changed = None
960
961 self._rebridge_unsubs[client_id] = _combined_unsub
962
963 def _on_player_state_updated(
964 self, updated_player: Player, changed_values: dict[str, tuple[Any, Any]]
965 ) -> None:
966 """Re-evaluate bridge state after a relevant player state change."""
967 if not changed_values.keys() & _BRIDGE_POLICY_STATE_FIELDS:
968 return
969 match_ids = {updated_player.player_id}
970 if updated_player.protocol_parent_id:
971 match_ids.add(updated_player.protocol_parent_id)
972 self._schedule_bridge_evaluations(match_ids)
973
974 def _on_player_unregistered(self, event: MassEvent) -> None:
975 """Re-evaluate Cast bridges after a possible parent is unregistered."""
976 if not event.object_id or self.mass.players.get_player(event.object_id):
977 return
978 self._schedule_bridge_evaluations({event.object_id})
979
980 def _schedule_bridge_evaluations(self, match_ids: set[str]) -> None:
981 """Schedule reconciliation for Cast bridges matching a player ID."""
982 for player in self.provider.players:
983 cast_player = cast("ChromecastPlayer", player)
984 if cast_player.player_id in match_ids or (
985 cast_player.protocol_parent_id and cast_player.protocol_parent_id in match_ids
986 ):
987 self._pending_bridge_evaluations.add(cast_player.player_id)
988 self.mass.create_task(
989 self._process_pending_bridge_evaluations,
990 cast_player.player_id,
991 task_id=f"evaluate_chromecast_sendspin_bridge_{cast_player.player_id}",
992 )
993
994 async def _process_pending_bridge_evaluations(self, player_id: str) -> None:
995 """
996 Reconcile all bridge policy updates queued for a Chromecast player.
997
998 :param player_id: The Chromecast player ID to reconcile.
999 """
1000 while player_id in self._pending_bridge_evaluations:
1001 self._pending_bridge_evaluations.discard(player_id)
1002 player = self.mass.players.get_player(player_id)
1003 if player is None or player.provider is not self.provider:
1004 return
1005 await self.evaluate_bridge(player)
1006
1007 async def _on_player_config_updated(self, event: MassEvent) -> None:
1008 """Re-evaluate bridges and push runtime config updates to active Cast apps."""
1009 await super()._on_player_config_updated(event)
1010 if not event.object_id:
1011 return
1012
1013 bridge: SendspinChromecastBridge | None = None
1014 async with self._lock:
1015 for candidate in self._bridges.values():
1016 if candidate.bridge_client_id == event.object_id:
1017 bridge = candidate
1018 break
1019
1020 if not bridge:
1021 return
1022
1023 if not bridge.is_cast_app_active:
1024 return
1025
1026 self.mass.create_task(
1027 bridge.push_runtime_config_update,
1028 task_id=f"chromecast_sendspin_config_update_{bridge.cast_player.player_id}",
1029 abort_existing=True,
1030 )
1031