66import contextlib
77import logging
88import signal
9- import socket
109from dataclasses import dataclass
11- from functools import partial
1210
1311from aiohttp import ClientError
1412from aiosendspin .client import SendspinClient
@@ -29,9 +27,9 @@ class DaemonConfig:
2927 """Configuration for the Sendspin daemon."""
3028
3129 audio_device : AudioDevice
30+ client_id : str
31+ client_name : str
3232 url : str | None = None
33- client_id : str | None = None
34- client_name : str | None = None
3533 static_delay_ms : float = 0.0
3634
3735
@@ -41,34 +39,9 @@ class SendspinDaemon:
4139 def __init__ (self , config : DaemonConfig ) -> None :
4240 """Initialize the daemon."""
4341 self ._config = config
44- self ._client : SendspinClient | None = None
45- self ._audio_handler : AudioStreamHandler | None = None
46- self ._discovery : ServiceDiscovery | None = None
47- self ._shutdown_event : asyncio .Event | None = None
48-
49- async def run (self ) -> int :
50- """Run the daemon."""
51- config = self ._config
52-
53- # Get hostname for defaults if needed
54- client_id = config .client_id
55- client_name = config .client_name
56- if client_id is None or client_name is None :
57- hostname = socket .gethostname ()
58- if not hostname :
59- logger .error ("Unable to determine hostname. Please specify --id and/or --name" )
60- return 1
61- # Auto-generate client ID and name from hostname
62- if client_id is None :
63- client_id = f"sendspin-cli-{ hostname } "
64- if client_name is None :
65- client_name = hostname
66-
67- logger .info ("Starting Sendspin daemon: %s" , client_id )
68-
6942 self ._client = SendspinClient (
70- client_id = client_id ,
71- client_name = client_name ,
43+ client_id = config . client_id ,
44+ client_name = config . client_name ,
7245 roles = [Roles .PLAYER ],
7346 device_info = get_device_info (),
7447 player_support = ClientHelloPlayerSupport (
@@ -85,90 +58,62 @@ async def run(self) -> int:
8558 ),
8659 static_delay_ms = config .static_delay_ms ,
8760 )
88-
61+ self . _audio_handler = AudioStreamHandler ( audio_device = config . audio_device )
8962 self ._discovery = ServiceDiscovery ()
63+ self ._shutdown_event = asyncio .Event ()
64+
65+ async def run (self ) -> int :
66+ """Run the daemon."""
67+ logger .info ("Starting Sendspin daemon: %s" , self ._client ._client_id )
68+ url = self ._config .url
69+ loop = asyncio .get_running_loop ()
70+
71+ def signal_handler () -> None :
72+ logger .debug ("Received interrupt signal, shutting down..." )
73+ self ._shutdown_event .set ()
74+
75+ # Register signal handlers
76+ with contextlib .suppress (NotImplementedError ):
77+ loop .add_signal_handler (signal .SIGINT , signal_handler )
78+ loop .add_signal_handler (signal .SIGTERM , signal_handler )
79+
9080 await self ._discovery .start ()
9181
9282 try :
93- # Get initial server URL
94- url = config .url
9583 if url is None :
9684 logger .info ("Waiting for mDNS discovery of Sendspin server..." )
97- try :
98- url = await self ._discovery .wait_for_first_server ()
99- logger .info ("Discovered Sendspin server at %s" , url )
100- except asyncio .CancelledError :
101- return 1
102- except Exception :
103- logger .exception ("Failed to discover server" )
104- return 1
105-
106- logger .info (
107- "Using audio device %d: %s" ,
108- config .audio_device .index ,
109- config .audio_device .name ,
110- )
85+ url = await self ._discover_server ()
86+ if self ._shutdown_event .is_set ():
87+ return 0
11188
11289 listeners = ClientListenerManager ()
113-
114- self ._audio_handler = AudioStreamHandler (audio_device = config .audio_device )
11590 self ._audio_handler .attach_client (self ._client , listeners )
116-
11791 listeners .attach (self ._client )
11892
119- loop = asyncio .get_running_loop ()
120-
121- self ._shutdown_event = asyncio .Event ()
122-
123- def signal_handler () -> None :
124- logger .debug ("Received interrupt signal, shutting down..." )
125- if self ._shutdown_event is not None :
126- self ._shutdown_event .set ()
127-
128- # Register signal handlers
129- with contextlib .suppress (NotImplementedError ):
130- loop .add_signal_handler (signal .SIGINT , signal_handler )
131- loop .add_signal_handler (signal .SIGTERM , signal_handler )
132-
133- try :
134- await self ._connection_loop (url , use_discovery = config .url is None )
135- finally :
136- # Remove signal handlers
137- with contextlib .suppress (NotImplementedError ):
138- loop .remove_signal_handler (signal .SIGINT )
139- loop .remove_signal_handler (signal .SIGTERM )
140- await self ._audio_handler .cleanup ()
141- await self ._client .disconnect ()
142- logger .info ("Daemon stopped" )
93+ await self ._connection_loop (url , use_discovery = self ._config .url is None )
14394
14495 finally :
145- # Stop discovery
96+ await self ._audio_handler .cleanup ()
97+ await self ._client .disconnect ()
14698 await self ._discovery .stop ()
99+ logger .info ("Daemon stopped" )
147100
148101 return 0
149102
150103 async def _connection_loop (self , initial_url : str , use_discovery : bool ) -> None :
151104 """Run the connection loop with automatic reconnection."""
152- assert self ._client is not None
153- assert self ._discovery is not None
154- assert self ._audio_handler is not None
155- assert self ._shutdown_event is not None
156-
157105 url = initial_url
158106 error_backoff = 1.0
159107 max_backoff = 300.0
108+ disconnect_event = asyncio .Event ()
109+ self ._client .set_disconnect_listener (disconnect_event .set )
160110
161111 while not self ._shutdown_event .is_set ():
112+ disconnect_event .clear ()
113+
162114 try :
163- logger .info ("Connecting to %s" , url )
164115 await self ._client .connect (url )
165- logger .info ("Connected to %s" , url )
166116 error_backoff = 1.0
167-
168- # Wait for disconnect or shutdown
169- disconnect_event = asyncio .Event ()
170- self ._client .set_disconnect_listener (partial (asyncio .Event .set , disconnect_event ))
171-
172117 shutdown_task = create_task (self ._shutdown_event .wait ())
173118 disconnect_task = create_task (disconnect_event .wait ())
174119
@@ -182,8 +127,6 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None:
182127 with contextlib .suppress (asyncio .CancelledError ):
183128 await task
184129
185- self ._client .set_disconnect_listener (None )
186-
187130 if shutdown_task in done :
188131 break
189132
@@ -192,21 +135,7 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None:
192135 await self ._audio_handler .cleanup ()
193136
194137 if use_discovery :
195- # Try to get new URL from discovery, or use last known URL
196- new_url = self ._discovery .current_url ()
197- if new_url :
198- url = new_url
199-
200- # Wait for server to reappear if discovery shows nothing
201- if not self ._discovery .current_url ():
202- logger .info ("Server offline, waiting for rediscovery..." )
203- while not self ._shutdown_event .is_set ():
204- new_url = self ._discovery .current_url ()
205- if new_url :
206- url = new_url
207- break
208- await asyncio .sleep (1.0 )
209-
138+ url = await self ._discover_server ()
210139 if self ._shutdown_event .is_set ():
211140 break
212141
@@ -227,8 +156,8 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None:
227156 pass # Sleep completed, continue loop
228157
229158 # Check if URL changed while sleeping (only when using discovery)
230- if use_discovery :
231- new_url = self . _discovery . current_url ()
159+ if use_discovery and ( servers := self . _discovery . get_servers ()) :
160+ new_url = servers [ 0 ]. url
232161 if new_url and new_url != url :
233162 logger .info ("Server URL changed to %s" , new_url )
234163 url = new_url
@@ -239,9 +168,24 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None:
239168
240169 except Exception :
241170 logger .exception ("Unexpected error during connection" )
242- try :
243- await asyncio .wait_for (self ._shutdown_event .wait (), timeout = error_backoff )
244- break
245- except TimeoutError :
246- pass
247- error_backoff = min (error_backoff * 2 , max_backoff )
171+ break
172+
173+ async def _discover_server (self ) -> str :
174+ """Discover next server to use.
175+
176+ Returns empty string when shutdown is set.
177+ """
178+ next_server_task = create_task (self ._discovery .wait_for_server ())
179+
180+ if not next_server_task .done ():
181+ shutdown_task = create_task (self ._shutdown_event .wait ())
182+ await asyncio .wait (
183+ {shutdown_task , next_server_task },
184+ return_when = asyncio .FIRST_COMPLETED ,
185+ )
186+
187+ if self ._shutdown_event .is_set ():
188+ return ""
189+
190+ server = await next_server_task
191+ return server .url
0 commit comments