/
/
/
1"""Player Provider for Sendspin."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Callable
7from typing import TYPE_CHECKING, cast
8
9from aiosendspin.server import ClientAddedEvent, ClientRemovedEvent, SendspinEvent, SendspinServer
10from music_assistant_models.enums import ProviderFeature
11from music_assistant_models.errors import AlreadyRegisteredError
12
13from music_assistant.mass import MusicAssistant
14from music_assistant.models.player_provider import PlayerProvider
15from music_assistant.providers.sendspin.player import SendspinPlayer
16
17if TYPE_CHECKING:
18 from music_assistant_models.config_entries import ProviderConfig
19 from music_assistant_models.provider import ProviderManifest
20
21 from music_assistant.providers.hass import HomeAssistantProvider
22
23
24class SendspinProvider(PlayerProvider):
25 """Player Provider for Sendspin."""
26
27 server_api: SendspinServer
28 unregister_cbs: list[Callable[[], None]]
29 _pending_unregisters: dict[str, asyncio.Event]
30
31 def __init__(
32 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
33 ) -> None:
34 """Initialize a new Sendspin player provider."""
35 super().__init__(mass, manifest, config)
36 self.server_api = SendspinServer(
37 self.mass.loop, mass.server_id, "Music Assistant", self.mass.http_session
38 )
39 self._pending_unregisters = {}
40 self.unregister_cbs = [
41 self.server_api.add_event_listener(self.event_cb),
42 ]
43
44 def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
45 """Event callback registered to the sendspin server."""
46 match event:
47 case ClientAddedEvent(client_id):
48 self.mass.create_task(self._handle_client_added(client_id))
49 case ClientRemovedEvent(client_id):
50 self.mass.create_task(self._handle_client_removed(client_id))
51 case _:
52 self.logger.error("Unknown sendspin event: %s", event)
53
54 async def _handle_client_added(self, client_id: str) -> None:
55 """Handle a new client connection asynchronously."""
56 # Wait for any pending unregister to complete before registering
57 # This prevents a race condition where a slow unregister removes
58 # a newly registered player after a quick reconnect
59 if pending_event := self._pending_unregisters.get(client_id):
60 self.logger.debug("Waiting for pending unregister of %s before registering", client_id)
61 await pending_event.wait()
62 # Check if client still exists (may have disconnected while waiting)
63 sendspin_client = self.server_api.get_client(client_id)
64 if sendspin_client is None:
65 self.logger.debug("Client %s gone after waiting for pending unregister", client_id)
66 return
67 # Wait for client hello to be processed (info becomes available)
68 # ClientAddedEvent fires before the hello handshake completes
69 for _ in range(50): # Wait up to 5 seconds
70 if sendspin_client._info is not None:
71 break
72 await asyncio.sleep(0.1)
73 else:
74 self.logger.warning("Client %s hello not received within timeout", client_id)
75 return
76 if self.mass.players.get_player(client_id) is not None:
77 self.logger.debug(
78 "Client %s already registered, skipping duplicate add event", client_id
79 )
80 return
81 player = SendspinPlayer(self, client_id)
82 self.logger.debug("Client %s connected", client_id)
83 if player.device_info.manufacturer == "ESPHome" and (
84 hass := self.mass.get_provider("hass")
85 ):
86 # Try to get device name from Home Assistant for ESPHome devices
87 hass = cast("HomeAssistantProvider", hass)
88 if hass_device := await hass.get_device_by_connection(client_id):
89 player._attr_name = (
90 hass_device["name_by_user"] or hass_device["name"] or player.name
91 )
92 try:
93 await self.mass.players.register(player)
94 except AlreadyRegisteredError:
95 self.logger.debug("Client %s already registered while handling add event", client_id)
96 player.unsub_event_cb()
97 player.unsub_group_event_cb()
98
99 async def _handle_client_removed(self, client_id: str) -> None:
100 """Handle a client disconnection asynchronously."""
101 self.logger.debug("Client %s disconnected", client_id)
102 unregister_event = asyncio.Event()
103 self._pending_unregisters[client_id] = unregister_event
104 try:
105 await self.mass.players.unregister(client_id)
106 finally:
107 self._pending_unregisters.pop(client_id, None)
108 unregister_event.set()
109
110 @property
111 def supported_features(self) -> set[ProviderFeature]:
112 """Return the features supported by this Provider."""
113 return {
114 ProviderFeature.SYNC_PLAYERS,
115 }
116
117 async def loaded_in_mass(self) -> None:
118 """Call after the provider has been loaded."""
119 await super().loaded_in_mass()
120 # Start server for handling incoming Sendspin connections from clients
121 # and mDNS discovery of new clients
122 await self.server_api.start_server(
123 port=8927,
124 host=self.mass.streams.bind_ip,
125 advertise_addresses=[cast("str", self.mass.streams.publish_ip)],
126 )
127
128 async def unload(self, is_removed: bool = False) -> None:
129 """
130 Handle unload/close of the provider.
131
132 Called when provider is deregistered (e.g. MA exiting or config reloading).
133
134 :param is_removed: True when the provider is removed from the configuration.
135 """
136 # Disconnect all clients before stopping the server
137 clients = list(self.server_api.clients)
138 connected_clients = []
139 disconnect_tasks = []
140 for client in clients:
141 if client.connection is None:
142 continue
143 connected_clients.append(client)
144 disconnect_tasks.append(client.connection.disconnect(retry_connection=False))
145 if disconnect_tasks:
146 results = await asyncio.gather(*disconnect_tasks, return_exceptions=True)
147 for client, result in zip(connected_clients, results, strict=True):
148 if isinstance(result, Exception):
149 self.logger.warning(
150 "Error disconnecting client %s: %s", client.client_id, result
151 )
152
153 # Stop the Sendspin server
154 await self.server_api.close()
155
156 for cb in self.unregister_cbs:
157 cb()
158 self.unregister_cbs = []
159