/
/
/
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 if self.server_api.get_client(client_id) is None:
64 self.logger.debug("Client %s gone after waiting for pending unregister", client_id)
65 return
66 if self.mass.players.get_player(client_id) is not None:
67 self.logger.debug(
68 "Client %s already registered, skipping duplicate add event", client_id
69 )
70 return
71 player = SendspinPlayer(self, client_id)
72 self.logger.debug("Client %s connected", client_id)
73 if player.device_info.manufacturer == "ESPHome" and (
74 hass := self.mass.get_provider("hass")
75 ):
76 # Try to get device name from Home Assistant for ESPHome devices
77 hass = cast("HomeAssistantProvider", hass)
78 if hass_device := await hass.get_device_by_connection(client_id):
79 player._attr_name = (
80 hass_device["name_by_user"] or hass_device["name"] or player.name
81 )
82 try:
83 await self.mass.players.register(player)
84 except AlreadyRegisteredError:
85 self.logger.debug("Client %s already registered while handling add event", client_id)
86 player.unsub_event_cb()
87 player.unsub_group_event_cb()
88
89 async def _handle_client_removed(self, client_id: str) -> None:
90 """Handle a client disconnection asynchronously."""
91 self.logger.debug("Client %s disconnected", client_id)
92 unregister_event = asyncio.Event()
93 self._pending_unregisters[client_id] = unregister_event
94 try:
95 await self.mass.players.unregister(client_id)
96 finally:
97 self._pending_unregisters.pop(client_id, None)
98 unregister_event.set()
99
100 @property
101 def supported_features(self) -> set[ProviderFeature]:
102 """Return the features supported by this Provider."""
103 return {
104 ProviderFeature.SYNC_PLAYERS,
105 }
106
107 async def loaded_in_mass(self) -> None:
108 """Call after the provider has been loaded."""
109 await super().loaded_in_mass()
110 # Start server for handling incoming Sendspin connections from clients
111 # and mDNS discovery of new clients
112 await self.server_api.start_server(
113 port=8927,
114 host=self.mass.streams.bind_ip,
115 advertise_addresses=[cast("str", self.mass.streams.publish_ip)],
116 )
117
118 async def unload(self, is_removed: bool = False) -> None:
119 """
120 Handle unload/close of the provider.
121
122 Called when provider is deregistered (e.g. MA exiting or config reloading).
123
124 :param is_removed: True when the provider is removed from the configuration.
125 """
126 # Disconnect all clients before stopping the server
127 clients = list(self.server_api.clients)
128 connected_clients = []
129 disconnect_tasks = []
130 for client in clients:
131 if client.connection is None:
132 continue
133 connected_clients.append(client)
134 disconnect_tasks.append(client.connection.disconnect(retry_connection=False))
135 if disconnect_tasks:
136 results = await asyncio.gather(*disconnect_tasks, return_exceptions=True)
137 for client, result in zip(connected_clients, results, strict=True):
138 if isinstance(result, Exception):
139 self.logger.warning(
140 "Error disconnecting client %s: %s", client.client_id, result
141 )
142
143 # Stop the Sendspin server
144 await self.server_api.close()
145
146 for cb in self.unregister_cbs:
147 cb()
148 self.unregister_cbs = []
149