/
/
/
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
11
12from music_assistant.mass import MusicAssistant
13from music_assistant.models.player_provider import PlayerProvider
14from music_assistant.providers.sendspin.player import SendspinPlayer
15
16if TYPE_CHECKING:
17 from music_assistant_models.config_entries import ProviderConfig
18 from music_assistant_models.provider import ProviderManifest
19
20 from music_assistant.providers.hass import HomeAssistantProvider
21
22
23class SendspinProvider(PlayerProvider):
24 """Player Provider for Sendspin."""
25
26 server_api: SendspinServer
27 unregister_cbs: list[Callable[[], None]]
28 _pending_unregisters: dict[str, asyncio.Event]
29
30 def __init__(
31 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
32 ) -> None:
33 """Initialize a new Sendspin player provider."""
34 super().__init__(mass, manifest, config)
35 self.server_api = SendspinServer(
36 self.mass.loop, mass.server_id, "Music Assistant", self.mass.http_session
37 )
38 self._pending_unregisters = {}
39 self.unregister_cbs = [
40 self.server_api.add_event_listener(self.event_cb),
41 ]
42
43 def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
44 """Event callback registered to the sendspin server."""
45 self.logger.debug("Received SendspinEvent: %s", event)
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 client added event 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 player = SendspinPlayer(self, client_id)
67 self.logger.debug("Client %s connected", client_id)
68 if player.device_info.manufacturer == "ESPHome" and (
69 hass := self.mass.get_provider("hass")
70 ):
71 # Try to get device name from Home Assistant for ESPHome devices
72 hass = cast("HomeAssistantProvider", hass)
73 if hass_device := await hass.get_device_by_connection(client_id):
74 player._attr_name = (
75 hass_device["name_by_user"] or hass_device["name"] or player.name
76 )
77 await self.mass.players.register(player)
78
79 async def _handle_client_removed(self, client_id: str) -> None:
80 """Handle client removed event asynchronously."""
81 self.logger.debug("Client %s disconnected", client_id)
82 unregister_event = asyncio.Event()
83 self._pending_unregisters[client_id] = unregister_event
84 try:
85 await self.mass.players.unregister(client_id)
86 finally:
87 self._pending_unregisters.pop(client_id, None)
88 unregister_event.set()
89
90 @property
91 def supported_features(self) -> set[ProviderFeature]:
92 """Return the features supported by this Provider."""
93 return {
94 ProviderFeature.SYNC_PLAYERS,
95 }
96
97 async def loaded_in_mass(self) -> None:
98 """Call after the provider has been loaded."""
99 await super().loaded_in_mass()
100 # Start server for handling incoming Sendspin connections from clients
101 # and mDNS discovery of new clients
102 await self.server_api.start_server(
103 port=8927,
104 host=self.mass.streams.bind_ip,
105 advertise_addresses=[cast("str", self.mass.streams.publish_ip)],
106 )
107
108 async def unload(self, is_removed: bool = False) -> None:
109 """
110 Handle unload/close of the provider.
111
112 Called when provider is deregistered (e.g. MA exiting or config reloading).
113
114 :param is_removed: True when the provider is removed from the configuration.
115 """
116 # Disconnect all clients before stopping the server
117 clients = list(self.server_api.clients)
118 disconnect_tasks = []
119 for client in clients:
120 self.logger.debug("Disconnecting client %s", client.client_id)
121 disconnect_tasks.append(client.disconnect(retry_connection=False))
122 if disconnect_tasks:
123 results = await asyncio.gather(*disconnect_tasks, return_exceptions=True)
124 for client, result in zip(clients, results, strict=True):
125 if isinstance(result, Exception):
126 self.logger.warning(
127 "Error disconnecting client %s: %s", client.client_id, result
128 )
129
130 # Stop the Sendspin server
131 await self.server_api.close()
132
133 for cb in self.unregister_cbs:
134 cb()
135 self.unregister_cbs = []
136