Skip to content

Commit 8380cbb

Browse files
authored
Clean up all the things (#36)
1 parent c3c354c commit 8380cbb

7 files changed

Lines changed: 457 additions & 586 deletions

File tree

‎sendspin/cli.py‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44

55
import argparse
66
import asyncio
7+
import logging
78
import sys
89
from collections.abc import Sequence
10+
import socket
911
from typing import TYPE_CHECKING
1012

1113
if TYPE_CHECKING:
1214
from sendspin.audio import AudioDevice
1315

16+
LOGGER = logging.getLogger(__name__)
17+
1418
PORTAUDIO_NOT_FOUND_MESSAGE = """Error: PortAudio library not found.
1519
1620
Please install PortAudio for your system:
@@ -197,8 +201,6 @@ async def list_servers() -> None:
197201
print(f" {server.name}")
198202
print(f" URL: {server.url}")
199203
print(f" Host: {server.host}:{server.port}")
200-
if servers:
201-
print(f"\nTo connect to a server:\n sendspin --url {servers[0].url}")
202204
except Exception as e: # noqa: BLE001
203205
print(f"Error discovering servers: {e}")
204206
sys.exit(1)
@@ -241,23 +243,47 @@ def _resolve_audio_device(device_arg: str | None) -> AudioDevice:
241243
dev_type = "Default" if device_arg is None else "Specified"
242244
raise CLIError(f"{dev_type} audio device not found.")
243245

246+
LOGGER.info(
247+
"Using audio device %d: %s",
248+
device.index,
249+
device.name,
250+
)
251+
244252
return device
245253

246254

247-
def _run_daemon_mode(args: argparse.Namespace) -> int:
255+
def _resolve_client_info(client_id: str | None, client_name: str | None) -> tuple[str, str]:
256+
"""Determine client ID and name."""
257+
# Get hostname for defaults if needed
258+
if client_id is None or client_name is None:
259+
hostname = socket.gethostname()
260+
if not hostname:
261+
raise CLIError("Unable to determine hostname. Please specify --id and/or --name", 1)
262+
# Auto-generate client ID and name from hostname
263+
if client_id is None:
264+
client_id = f"sendspin-cli-{hostname}"
265+
if client_name is None:
266+
client_name = hostname
267+
268+
return client_id, client_name
269+
270+
271+
async def _run_daemon_mode(args: argparse.Namespace) -> int:
248272
"""Run the client in daemon mode (no UI)."""
249273
from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon
250274

275+
client_id, client_name = _resolve_client_info(args.id, args.name)
276+
251277
daemon_config = DaemonConfig(
252278
audio_device=_resolve_audio_device(args.audio_device),
253279
url=args.url,
254-
client_id=args.id,
255-
client_name=args.name,
280+
client_id=client_id,
281+
client_name=client_name,
256282
static_delay_ms=args.static_delay_ms,
257283
)
258284

259285
daemon = SendspinDaemon(daemon_config)
260-
return asyncio.run(daemon.run())
286+
return await daemon.run()
261287

262288

263289
def main() -> int:
@@ -308,7 +334,7 @@ def main() -> int:
308334
return 0
309335

310336
try:
311-
return _run_client_mode(args)
337+
return asyncio.run(_run_client_mode(args))
312338
except CLIError as e:
313339
print(f"Error: {e}")
314340
return e.exit_code
@@ -319,30 +345,32 @@ def main() -> int:
319345
raise
320346

321347

322-
def _run_client_mode(args: argparse.Namespace) -> int:
348+
async def _run_client_mode(args: argparse.Namespace) -> int:
323349
"""Run the client in TUI or daemon mode."""
324350
# Handle daemon subcommand
325351
if args.command == "daemon":
326-
return _run_daemon_mode(args)
352+
return await _run_daemon_mode(args)
327353

328354
# Handle deprecated --headless flag
329355
if args.headless:
330356
print("Warning: --headless is deprecated. Use 'sendspin daemon' instead.")
331357
print("Routing to daemon mode...\n")
332-
return _run_daemon_mode(args)
358+
return await _run_daemon_mode(args)
333359

334360
from sendspin.tui.app import AppConfig, SendspinApp
335361

362+
client_id, client_name = _resolve_client_info(args.id, args.name)
363+
336364
app_config = AppConfig(
337365
audio_device=_resolve_audio_device(args.audio_device),
338366
url=args.url,
339-
client_id=args.id,
340-
client_name=args.name,
367+
client_id=client_id,
368+
client_name=client_name,
341369
static_delay_ms=args.static_delay_ms,
342370
)
343371

344372
app = SendspinApp(app_config)
345-
return asyncio.run(app.run())
373+
return await app.run()
346374

347375

348376
if __name__ == "__main__":

‎sendspin/daemon/daemon.py‎

Lines changed: 57 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
import contextlib
77
import logging
88
import signal
9-
import socket
109
from dataclasses import dataclass
11-
from functools import partial
1210

1311
from aiohttp import ClientError
1412
from 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

Comments
 (0)