/
/
/
1"""
2Shared lifecycle management for Sendspin bridges.
3
4A Sendspin bridge exposes a player of another protocol (AirPlay, Chromecast,
5a local soundcard) as an external Sendspin client, so the device can take part
6in Sendspin synchronized playback. The bridge is a derived transport: it can
7only exist while the player it rides on exists and is enabled.
8
9SendspinBridgeManagerBase reconciles that lifecycle in one place. Providers
10subclass it and only supply policy (should this player have a bridge?) and a
11bridge factory; the base class reacts to config changes and provider
12(un)loads, tears bridges down when their base player is disabled and recreates
13them when it returns.
14"""
15
16from __future__ import annotations
17
18import asyncio
19from abc import ABC, abstractmethod
20from contextlib import suppress
21from typing import TYPE_CHECKING, Protocol, cast
22
23from music_assistant_models.enums import EventType
24
25from music_assistant.constants import CONF_PLAYERS, CONF_PROTOCOL_PARENT_ID
26
27if TYPE_CHECKING:
28 from collections.abc import Callable
29
30 from aiosendspin.server import SendspinServer
31 from music_assistant_models.event import MassEvent
32
33 from music_assistant.mass import MusicAssistant
34 from music_assistant.models.player import Player
35 from music_assistant.models.player_provider import PlayerProvider
36
37 from .provider import SendspinProvider
38
39
40class SendspinBridge(Protocol):
41 """Interface a Sendspin bridge implementation must provide."""
42
43 sendspin_server: SendspinServer
44
45 @property
46 def is_registered(self) -> bool:
47 """Return whether the bridge is registered with Sendspin."""
48
49 async def start(self) -> None:
50 """Register the bridge as an external Sendspin client."""
51
52 async def stop(self) -> None:
53 """Stop and unregister the bridge."""
54
55
56class SendspinBridgeManagerBase[BridgeT: SendspinBridge](ABC):
57 """
58 Base class managing the Sendspin bridges for one player provider.
59
60 Owns the desired-state reconciliation: a bridge exists if and only if the
61 provider policy wants one (_should_have_bridge) and the lifecycle allows it
62 (base player registered and enabled, bridge client enabled, Sendspin
63 provider available). Subclasses provide the policy and the bridge factory.
64 """
65
66 def __init__(self, provider: PlayerProvider) -> None:
67 """
68 Initialize the bridge manager.
69
70 :param provider: The player provider owning the bridged players.
71 """
72 self.provider = provider
73 self.mass: MusicAssistant = provider.mass
74 self.logger = provider.logger.getChild("bridge_manager")
75 self._bridges: dict[str, BridgeT] = {}
76 self._lock = asyncio.Lock()
77 # base player_id -> bridge client_id, kept for config-event routing
78 # (also for players that currently have no bridge)
79 self._client_ids: dict[str, str] = {}
80 self._unsubs: list[Callable[[], None]] = [
81 self.mass.subscribe(self._on_player_config_updated, EventType.PLAYER_CONFIG_UPDATED),
82 self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED),
83 ]
84
85 @property
86 def sendspin_provider(self) -> SendspinProvider | None:
87 """Get the Sendspin provider if available."""
88 return cast("SendspinProvider | None", self.mass.get_provider("sendspin"))
89
90 @property
91 def sendspin_server(self) -> SendspinServer | None:
92 """Get the Sendspin server if available."""
93 if provider := self.sendspin_provider:
94 return provider.server_api
95 return None
96
97 async def evaluate_bridge(self, player: Player) -> None:
98 """
99 Reconcile the Sendspin bridge state for a player.
100
101 Creates, recreates or removes the bridge to match the desired state
102 derived from configuration and the current player graph. Idempotent
103 and safe to call repeatedly.
104
105 :param player: The player to evaluate.
106 """
107 player_id = player.player_id
108 if client_id := self._bridge_client_id(player):
109 self._client_ids[player_id] = client_id
110 if not self._should_have_bridge(player):
111 # Provider policy: this player should not have a bridge at all,
112 # so also clean up the bridge client player/config. Only act on
113 # bridges we actually own - the computed client_id may collide
114 # with another protocol's bridge on the same device (shared MAC).
115 if self._has_bridge(player_id):
116 await self.remove_bridge(player_id, permanent=True)
117 return
118 if client_id:
119 await self._heal_stale_client_disable(player, client_id)
120 if not self._lifecycle_allows_bridge(player):
121 if self._has_bridge(player_id):
122 await self.remove_bridge(player_id)
123 return
124 if (bridge := self._bridges.get(player_id)) and self._is_bridge_stale(bridge):
125 # The Sendspin provider was reloaded; rebuild against the new server
126 await self.remove_bridge(player_id)
127 if not self._has_bridge(player_id):
128 await self.setup_bridge(player)
129
130 async def setup_bridge(self, player: Player) -> None:
131 """
132 Set up a Sendspin bridge for the given player.
133
134 No-op when a bridge is already in place or the current state does not
135 want/allow one.
136
137 :param player: The player to bridge.
138 """
139 async with self._lock:
140 player_id = player.player_id
141 if self._has_bridge(player_id):
142 self.logger.debug("Bridge already exists for %s", player.display_name)
143 return
144 if not (self._should_have_bridge(player) and self._lifecycle_allows_bridge(player)):
145 return
146 if await self._try_claim_existing(player):
147 return
148 bridge: BridgeT | None = None
149 try:
150 bridge = self._create_bridge(player)
151 await bridge.start()
152 except Exception:
153 self.logger.warning("Failed to start Sendspin bridge for %s", player.display_name)
154 if bridge is not None:
155 with suppress(Exception):
156 await bridge.stop()
157 return
158 if not bridge.is_registered:
159 return
160 self._bridges[player_id] = bridge
161 self.logger.info("Sendspin bridge created for %s", player.display_name)
162
163 async def remove_bridge(self, player_id: str, permanent: bool = False) -> None:
164 """
165 Remove the Sendspin bridge for a player.
166
167 :param player_id: The (base) player ID to remove the bridge for.
168 :param permanent: Also remove the bridge client player and its config.
169 Use when the player should not have a bridge at all; plain removal
170 keeps the config so user settings survive a re-enable.
171 """
172 async with self._lock:
173 bridge = self._bridges.pop(player_id, None)
174 if bridge:
175 with suppress(Exception):
176 await bridge.stop()
177 removed_any = bridge is not None
178 if permanent and (client_id := self._client_ids.get(player_id)):
179 if self.mass.players.get_player(client_id):
180 await self.mass.players.unregister(client_id, permanent=True)
181 removed_any = True
182 elif self.mass.config.get(f"{CONF_PLAYERS}/{client_id}"):
183 self.mass.players.delete_player_config(client_id)
184 removed_any = True
185 if removed_any:
186 self.logger.debug("Sendspin bridge removed for player %s", player_id)
187
188 def get_bridge(self, player_id: str) -> BridgeT | None:
189 """
190 Get the bridge for a player.
191
192 :param player_id: The (base) player ID to look up.
193 """
194 return self._bridges.get(player_id)
195
196 async def stop_all(self) -> None:
197 """Stop all Sendspin bridges."""
198 async with self._lock:
199 for bridge in list(self._bridges.values()):
200 with suppress(Exception):
201 await bridge.stop()
202 self._bridges.clear()
203 self.logger.debug("All Sendspin bridges stopped")
204
205 async def close(self) -> None:
206 """Stop all bridges and unsubscribe event listeners."""
207 for unsub in self._unsubs:
208 with suppress(Exception):
209 unsub()
210 self._unsubs.clear()
211 await self.stop_all()
212
213 @abstractmethod
214 def _bridge_client_id(self, player: Player) -> str | None:
215 """
216 Return the Sendspin client_id used to bridge the given player.
217
218 :param player: The player to resolve the bridge client_id for.
219 :return: The client_id, or None when the player cannot be bridged.
220 """
221
222 @abstractmethod
223 def _create_bridge(self, player: Player) -> BridgeT:
224 """
225 Create a (not yet started) bridge instance for the given player.
226
227 :param player: The player to create a bridge for.
228 """
229
230 @abstractmethod
231 def _should_have_bridge(self, player: Player) -> bool:
232 """
233 Return whether provider policy wants a bridge for this player.
234
235 Policy conditions only (device capabilities, blocklists, preferred
236 alternative transports); lifecycle conditions such as enabled state
237 and Sendspin availability are handled by the base class.
238
239 :param player: The player to evaluate.
240 """
241
242 async def _try_claim_existing(self, player: Player) -> bool:
243 """
244 Claim an already-registered external Sendspin client for this player.
245
246 Default implementation claims nothing; subclasses can override to
247 adopt clients that connected on their own (e.g. a JS Cast receiver).
248
249 :param player: The player being bridged.
250 :return: True when an existing client was claimed (skips bridge setup).
251 """
252 return False
253
254 def _has_bridge(self, player_id: str) -> bool:
255 """Return whether a bridge is currently in place for the player."""
256 return player_id in self._bridges
257
258 def _lifecycle_allows_bridge(self, player: Player) -> bool:
259 """Return whether the current lifecycle state allows a bridge for the player."""
260 if self.sendspin_server is None:
261 return False
262 if self.mass.players.get_player(player.player_id) is not player:
263 # not (or no longer) the registered player instance
264 return False
265 if not self._is_player_enabled(player.player_id):
266 return False
267 if client_id := self._client_ids.get(player.player_id):
268 # the bridge client (= the derived Sendspin protocol player) can be
269 # disabled by the user independently of the base player
270 return self._is_player_enabled(client_id)
271 return True
272
273 def _is_bridge_stale(self, bridge: BridgeT) -> bool:
274 """Return whether the bridge is bound to a stale (replaced) Sendspin server."""
275 return bridge.sendspin_server is not self.sendspin_server
276
277 def _is_player_enabled(self, player_id: str) -> bool:
278 """Return the persisted enabled state for a player (default True)."""
279 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
280 if not raw_conf:
281 return True
282 return bool(raw_conf.get("enabled", True))
283
284 async def _heal_stale_client_disable(self, player: Player, client_id: str) -> None:
285 """
286 Re-enable a bridge client whose disabled state lost its owning parent.
287
288 :param player: The base player a bridge is wanted for.
289 :param client_id: The Sendspin bridge client_id computed for the player.
290 """
291 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{client_id}")
292 if not raw_conf or raw_conf.get("enabled", True):
293 return
294 if self.sendspin_server is None or not self._is_player_enabled(player.player_id):
295 return
296 # MAC-based client ids outlive the UUID-based parent ids they were
297 # disabled under; without the parent there is no UI toggle to undo it.
298 values = raw_conf.get("values") or {}
299 parent_id = values.get(CONF_PROTOCOL_PARENT_ID)
300 if parent_id and self.mass.config.get(f"{CONF_PLAYERS}/{parent_id}"):
301 # the parent the disable was made under still exists - respect it
302 return
303 self.logger.info(
304 "Re-enabling Sendspin bridge client %s for %s: former parent no longer exists",
305 client_id,
306 player.display_name,
307 )
308 await self.mass.config.save_player_config(client_id, {"enabled": True})
309
310 async def _on_player_config_updated(self, event: MassEvent) -> None:
311 """Re-evaluate the bridge of a player affected by a config change."""
312 player_id = event.object_id
313 if not player_id:
314 return
315 # a config change on a bridge client maps back to its base player
316 for base_player_id, client_id in self._client_ids.items():
317 if client_id == player_id:
318 player_id = base_player_id
319 break
320 if player := self.mass.players.get_player(player_id):
321 if player.provider is not self.provider:
322 return
323 await self.evaluate_bridge(player)
324 elif self._has_bridge(player_id):
325 # base player disabled/unregistered - tear down its bridge
326 await self.remove_bridge(player_id)
327
328 async def _on_providers_updated(self, event: MassEvent) -> None:
329 """Re-evaluate all bridges when provider availability changes."""
330 for player in self.provider.players:
331 await self.evaluate_bridge(player)
332