diff --git a/sendspin/cli.py b/sendspin/cli.py index 1467eef..efdbc5b 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -4,13 +4,17 @@ import argparse import asyncio +import logging import sys from collections.abc import Sequence +import socket from typing import TYPE_CHECKING if TYPE_CHECKING: from sendspin.audio import AudioDevice +LOGGER = logging.getLogger(__name__) + PORTAUDIO_NOT_FOUND_MESSAGE = """Error: PortAudio library not found. Please install PortAudio for your system: @@ -197,8 +201,6 @@ async def list_servers() -> None: print(f" {server.name}") print(f" URL: {server.url}") print(f" Host: {server.host}:{server.port}") - if servers: - print(f"\nTo connect to a server:\n sendspin --url {servers[0].url}") except Exception as e: # noqa: BLE001 print(f"Error discovering servers: {e}") sys.exit(1) @@ -241,23 +243,47 @@ def _resolve_audio_device(device_arg: str | None) -> AudioDevice: dev_type = "Default" if device_arg is None else "Specified" raise CLIError(f"{dev_type} audio device not found.") + LOGGER.info( + "Using audio device %d: %s", + device.index, + device.name, + ) + return device -def _run_daemon_mode(args: argparse.Namespace) -> int: +def _resolve_client_info(client_id: str | None, client_name: str | None) -> tuple[str, str]: + """Determine client ID and name.""" + # Get hostname for defaults if needed + if client_id is None or client_name is None: + hostname = socket.gethostname() + if not hostname: + raise CLIError("Unable to determine hostname. Please specify --id and/or --name", 1) + # Auto-generate client ID and name from hostname + if client_id is None: + client_id = f"sendspin-cli-{hostname}" + if client_name is None: + client_name = hostname + + return client_id, client_name + + +async def _run_daemon_mode(args: argparse.Namespace) -> int: """Run the client in daemon mode (no UI).""" from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon + client_id, client_name = _resolve_client_info(args.id, args.name) + daemon_config = DaemonConfig( audio_device=_resolve_audio_device(args.audio_device), url=args.url, - client_id=args.id, - client_name=args.name, + client_id=client_id, + client_name=client_name, static_delay_ms=args.static_delay_ms, ) daemon = SendspinDaemon(daemon_config) - return asyncio.run(daemon.run()) + return await daemon.run() def main() -> int: @@ -308,7 +334,7 @@ def main() -> int: return 0 try: - return _run_client_mode(args) + return asyncio.run(_run_client_mode(args)) except CLIError as e: print(f"Error: {e}") return e.exit_code @@ -319,30 +345,32 @@ def main() -> int: raise -def _run_client_mode(args: argparse.Namespace) -> int: +async def _run_client_mode(args: argparse.Namespace) -> int: """Run the client in TUI or daemon mode.""" # Handle daemon subcommand if args.command == "daemon": - return _run_daemon_mode(args) + return await _run_daemon_mode(args) # Handle deprecated --headless flag if args.headless: print("Warning: --headless is deprecated. Use 'sendspin daemon' instead.") print("Routing to daemon mode...\n") - return _run_daemon_mode(args) + return await _run_daemon_mode(args) from sendspin.tui.app import AppConfig, SendspinApp + client_id, client_name = _resolve_client_info(args.id, args.name) + app_config = AppConfig( audio_device=_resolve_audio_device(args.audio_device), url=args.url, - client_id=args.id, - client_name=args.name, + client_id=client_id, + client_name=client_name, static_delay_ms=args.static_delay_ms, ) app = SendspinApp(app_config) - return asyncio.run(app.run()) + return await app.run() if __name__ == "__main__": diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 273cd88..c4bfd85 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -6,9 +6,7 @@ import contextlib import logging import signal -import socket from dataclasses import dataclass -from functools import partial from aiohttp import ClientError from aiosendspin.client import SendspinClient @@ -29,9 +27,9 @@ class DaemonConfig: """Configuration for the Sendspin daemon.""" audio_device: AudioDevice + client_id: str + client_name: str url: str | None = None - client_id: str | None = None - client_name: str | None = None static_delay_ms: float = 0.0 @@ -41,34 +39,9 @@ class SendspinDaemon: def __init__(self, config: DaemonConfig) -> None: """Initialize the daemon.""" self._config = config - self._client: SendspinClient | None = None - self._audio_handler: AudioStreamHandler | None = None - self._discovery: ServiceDiscovery | None = None - self._shutdown_event: asyncio.Event | None = None - - async def run(self) -> int: - """Run the daemon.""" - config = self._config - - # Get hostname for defaults if needed - client_id = config.client_id - client_name = config.client_name - if client_id is None or client_name is None: - hostname = socket.gethostname() - if not hostname: - logger.error("Unable to determine hostname. Please specify --id and/or --name") - return 1 - # Auto-generate client ID and name from hostname - if client_id is None: - client_id = f"sendspin-cli-{hostname}" - if client_name is None: - client_name = hostname - - logger.info("Starting Sendspin daemon: %s", client_id) - self._client = SendspinClient( - client_id=client_id, - client_name=client_name, + client_id=config.client_id, + client_name=config.client_name, roles=[Roles.PLAYER], device_info=get_device_info(), player_support=ClientHelloPlayerSupport( @@ -85,90 +58,62 @@ async def run(self) -> int: ), static_delay_ms=config.static_delay_ms, ) - + self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._discovery = ServiceDiscovery() + self._shutdown_event = asyncio.Event() + + async def run(self) -> int: + """Run the daemon.""" + logger.info("Starting Sendspin daemon: %s", self._client._client_id) + url = self._config.url + loop = asyncio.get_running_loop() + + def signal_handler() -> None: + logger.debug("Received interrupt signal, shutting down...") + self._shutdown_event.set() + + # Register signal handlers + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) + await self._discovery.start() try: - # Get initial server URL - url = config.url if url is None: logger.info("Waiting for mDNS discovery of Sendspin server...") - try: - url = await self._discovery.wait_for_first_server() - logger.info("Discovered Sendspin server at %s", url) - except asyncio.CancelledError: - return 1 - except Exception: - logger.exception("Failed to discover server") - return 1 - - logger.info( - "Using audio device %d: %s", - config.audio_device.index, - config.audio_device.name, - ) + url = await self._discover_server() + if self._shutdown_event.is_set(): + return 0 listeners = ClientListenerManager() - - self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._audio_handler.attach_client(self._client, listeners) - listeners.attach(self._client) - loop = asyncio.get_running_loop() - - self._shutdown_event = asyncio.Event() - - def signal_handler() -> None: - logger.debug("Received interrupt signal, shutting down...") - if self._shutdown_event is not None: - self._shutdown_event.set() - - # Register signal handlers - with contextlib.suppress(NotImplementedError): - loop.add_signal_handler(signal.SIGINT, signal_handler) - loop.add_signal_handler(signal.SIGTERM, signal_handler) - - try: - await self._connection_loop(url, use_discovery=config.url is None) - finally: - # Remove signal handlers - with contextlib.suppress(NotImplementedError): - loop.remove_signal_handler(signal.SIGINT) - loop.remove_signal_handler(signal.SIGTERM) - await self._audio_handler.cleanup() - await self._client.disconnect() - logger.info("Daemon stopped") + await self._connection_loop(url, use_discovery=self._config.url is None) finally: - # Stop discovery + await self._audio_handler.cleanup() + await self._client.disconnect() await self._discovery.stop() + logger.info("Daemon stopped") return 0 async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None: """Run the connection loop with automatic reconnection.""" - assert self._client is not None - assert self._discovery is not None - assert self._audio_handler is not None - assert self._shutdown_event is not None - url = initial_url error_backoff = 1.0 max_backoff = 300.0 + disconnect_event = asyncio.Event() + self._client.set_disconnect_listener(disconnect_event.set) while not self._shutdown_event.is_set(): + disconnect_event.clear() + try: - logger.info("Connecting to %s", url) await self._client.connect(url) - logger.info("Connected to %s", url) error_backoff = 1.0 - - # Wait for disconnect or shutdown - disconnect_event = asyncio.Event() - self._client.set_disconnect_listener(partial(asyncio.Event.set, disconnect_event)) - shutdown_task = create_task(self._shutdown_event.wait()) disconnect_task = create_task(disconnect_event.wait()) @@ -182,8 +127,6 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None: with contextlib.suppress(asyncio.CancelledError): await task - self._client.set_disconnect_listener(None) - if shutdown_task in done: break @@ -192,21 +135,7 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None: await self._audio_handler.cleanup() if use_discovery: - # Try to get new URL from discovery, or use last known URL - new_url = self._discovery.current_url() - if new_url: - url = new_url - - # Wait for server to reappear if discovery shows nothing - if not self._discovery.current_url(): - logger.info("Server offline, waiting for rediscovery...") - while not self._shutdown_event.is_set(): - new_url = self._discovery.current_url() - if new_url: - url = new_url - break - await asyncio.sleep(1.0) - + url = await self._discover_server() if self._shutdown_event.is_set(): break @@ -227,8 +156,8 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None: pass # Sleep completed, continue loop # Check if URL changed while sleeping (only when using discovery) - if use_discovery: - new_url = self._discovery.current_url() + if use_discovery and (servers := self._discovery.get_servers()): + new_url = servers[0].url if new_url and new_url != url: logger.info("Server URL changed to %s", new_url) url = new_url @@ -239,9 +168,24 @@ async def _connection_loop(self, initial_url: str, use_discovery: bool) -> None: except Exception: logger.exception("Unexpected error during connection") - try: - await asyncio.wait_for(self._shutdown_event.wait(), timeout=error_backoff) - break - except TimeoutError: - pass - error_backoff = min(error_backoff * 2, max_backoff) + break + + async def _discover_server(self) -> str: + """Discover next server to use. + + Returns empty string when shutdown is set. + """ + next_server_task = create_task(self._discovery.wait_for_server()) + + if not next_server_task.done(): + shutdown_task = create_task(self._shutdown_event.wait()) + await asyncio.wait( + {shutdown_task, next_server_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if self._shutdown_event.is_set(): + return "" + + server = await next_server_task + return server.url diff --git a/sendspin/discovery.py b/sendspin/discovery.py index 1620e3d..58b5ed2 100644 --- a/sendspin/discovery.py +++ b/sendspin/discovery.py @@ -6,6 +6,7 @@ import logging from dataclasses import dataclass from typing import TYPE_CHECKING, cast +from urllib.parse import urlparse if TYPE_CHECKING: from zeroconf import ServiceListener @@ -26,6 +27,22 @@ class DiscoveredServer: host: str port: int + @classmethod + def from_url(cls, name: str, url: str) -> DiscoveredServer: + """Create a discovered server.""" + parts = urlparse(url) + if parts.hostname is None: + raise ValueError("URL contains no hostname") + port = parts.port + if port is None: + port = 443 if parts.scheme in ("wss", "https") else 80 + return cls( + name=name, + url=url, + host=parts.hostname, + port=port, + ) + SERVICE_TYPE = "_sendspin-server._tcp.local." DEFAULT_PATH = "/sendspin" @@ -48,24 +65,19 @@ class _ServiceDiscoveryListener: def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop - self._current_url: str | None = None - self._first_result: asyncio.Future[str] = loop.create_future() + self._next_result: asyncio.Future[DiscoveredServer] | None = None self._servers: dict[str, DiscoveredServer] = {} - self.tasks: set[asyncio.Task[None]] = set() - - @property - def current_url(self) -> str | None: - """Get the current discovered server URL, or None if no servers.""" - return self._current_url @property def servers(self) -> dict[str, DiscoveredServer]: """Get all discovered servers.""" return self._servers - async def wait_for_first(self) -> str: + async def wait_for_next(self) -> DiscoveredServer: """Wait for the first server to be discovered.""" - return await self._first_result + if self._next_result is None: + self._next_result = self._loop.create_future() + return await self._next_result async def _process_service_info( self, zeroconf: AsyncZeroconf, service_type: str, name: str @@ -79,7 +91,6 @@ async def _process_service_info( return host = addresses[0] url = _build_service_url(host, info.port, info.properties) - self._current_url = url # Track this server self._servers[name] = DiscoveredServer( @@ -90,27 +101,19 @@ async def _process_service_info( ) # Signal first server discovery - if not self._first_result.done(): - self._first_result.set_result(url) - - def _schedule(self, zeroconf: AsyncZeroconf, service_type: str, name: str) -> None: - task = create_task( - self._process_service_info(zeroconf, service_type, name), loop=self._loop - ) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + if self._next_result and not self._next_result.done(): + self._next_result.set_result(self._servers[name]) + self._next_result = None def add_service(self, zeroconf: AsyncZeroconf, service_type: str, name: str) -> None: - self._schedule(zeroconf, service_type, name) + create_task(self._process_service_info(zeroconf, service_type, name), loop=self._loop) def update_service(self, zeroconf: AsyncZeroconf, service_type: str, name: str) -> None: - self._schedule(zeroconf, service_type, name) + create_task(self._process_service_info(zeroconf, service_type, name), loop=self._loop) def remove_service(self, _zeroconf: AsyncZeroconf, _service_type: str, name: str) -> None: """Handle service removal (server offline).""" self._servers.pop(name, None) - self._current_url = None class ServiceDiscovery: @@ -137,15 +140,16 @@ async def start(self) -> None: await self.stop() raise - async def wait_for_first_server(self) -> str: - """Wait indefinitely for the first server to be discovered.""" + async def wait_for_server(self) -> DiscoveredServer: + """Wait indefinitely for a server to be discovered. + + Will return directly if a server is currently known. + """ if self._listener is None: raise RuntimeError("Discovery not started. Call start() first.") - return await self._listener.wait_for_first() - - def current_url(self) -> str | None: - """Get the current discovered server URL, or None if no servers.""" - return self._listener.current_url if self._listener else None + if servers := self.get_servers(): + return servers[0] + return await self._listener.wait_for_next() def get_servers(self) -> list[DiscoveredServer]: """Get all discovered servers.""" diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 0fc47c5..d437e35 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -6,7 +6,6 @@ import contextlib import logging import signal -import socket import sys from dataclasses import dataclass, field from functools import partial @@ -40,7 +39,7 @@ from sendspin.audio import AudioDevice from sendspin.audio_connector import AudioStreamHandler from sendspin.client_listeners import ClientListenerManager -from sendspin.discovery import ServiceDiscovery +from sendspin.discovery import ServiceDiscovery, DiscoveredServer from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import SendspinUI from sendspin.utils import create_task, get_device_info @@ -52,6 +51,7 @@ class AppState: """Holds state mirrored from the server for CLI presentation.""" + selected_server: DiscoveredServer | None = None playback_state: PlaybackStateType | None = None supported_commands: set[MediaCommand] = field(default_factory=set) volume: int | None = None @@ -124,36 +124,38 @@ class ConnectionManager: def __init__( self, discovery: ServiceDiscovery, - keyboard_task: asyncio.Task[None], max_backoff: float = 300.0, ) -> None: """Initialize the connection manager.""" self._discovery = discovery - self._keyboard_task = keyboard_task self._error_backoff = 1.0 self._max_backoff = max_backoff self._last_attempted_url = "" - self._pending_url: str | None = None # URL set by user for server switch + self._pending_server: DiscoveredServer | None = None # URL set by user for server switch + + def set_keyboard_task(self, keyboard_task: asyncio.Task[None]) -> None: + self._keyboard_task = keyboard_task - def set_pending_url(self, url: str) -> None: - """Set a pending URL for server switch.""" - self._pending_url = url + def set_pending_server(self, server: DiscoveredServer) -> None: + """Set a pending server for server switch.""" + self._pending_server = server - def consume_pending_url(self) -> str | None: - """Get and clear the pending URL if set.""" - url = self._pending_url - self._pending_url = None - return url + def consume_pending_server(self) -> DiscoveredServer | None: + """Get and clear the pending server if set.""" + server = self._pending_server + self._pending_server = None + return server async def sleep_interruptible(self, duration: float) -> bool: """Sleep with keyboard interrupt support. Returns True if interrupted by keyboard, False if completed normally. """ - remaining = duration - while remaining > 0 and not self._keyboard_task.done(): - await asyncio.sleep(min(0.5, remaining)) - remaining -= 0.5 + try: + await asyncio.wait({self._keyboard_task}, timeout=duration) + except TimeoutError: + pass + return self._keyboard_task.done() def set_last_attempted_url(self, url: str) -> None: @@ -198,129 +200,25 @@ async def handle_error_backoff(self, ui: SendspinUI) -> bool: ui.add_event(f"Connection error, retrying in {self._error_backoff:.0f}s...") return await self.sleep_interruptible(self._error_backoff) - async def wait_for_server_reappear(self, ui: SendspinUI) -> str | None: + async def discover_server(self) -> DiscoveredServer | None: """Wait for server to reappear on the network. Returns the new URL if server reappears, None if interrupted. """ - logger.info("Server offline, waiting for rediscovery...") - ui.add_event("Waiting for server...") - - # Poll for discovery or keyboard exit - while not self._keyboard_task.done(): - new_url = self._discovery.current_url() - if new_url: - return new_url - await asyncio.sleep(1.0) - - return None - - -async def connection_loop( # noqa: PLR0915 - client: SendspinClient, - discovery: ServiceDiscovery, - audio_handler: AudioStreamHandler, - initial_url: str, - keyboard_task: asyncio.Task[None], - connection_manager: ConnectionManager, - ui: SendspinUI, -) -> None: - """ - Run the connection loop with automatic reconnection on disconnect. - - Connects to the server, waits for disconnect, cleans up, then retries - only if the server is visible via mDNS. Reconnects immediately when - server reappears. Uses exponential backoff (up to 5 min) for errors. - - Args: - client: Sendspin client instance. - discovery: Service discovery manager. - audio_handler: Audio stream handler. - initial_url: Initial server URL. - keyboard_task: Keyboard input task to monitor. - connection_manager: Connection manager for reconnection logic. - ui: UI instance. - """ - manager = connection_manager - url = initial_url - manager.set_last_attempted_url(url) - - while not keyboard_task.done(): - try: - await client.connect(url) - logger.info("Connected to %s", url) - ui.add_event(f"Connected to {url}") - ui.set_connected(url) - manager.reset_backoff() - manager.set_last_attempted_url(url) - - # Wait for disconnect or keyboard exit - disconnect_event: asyncio.Event = asyncio.Event() - client.set_disconnect_listener(partial(asyncio.Event.set, disconnect_event)) - done, _ = await asyncio.wait( - {keyboard_task, create_task(disconnect_event.wait())}, - return_when=asyncio.FIRST_COMPLETED, - ) - - client.set_disconnect_listener(None) - if keyboard_task in done: - break - - # Connection dropped - logger.info("Connection lost") - ui.add_event("Connection lost") - ui.set_disconnected("Connection lost") - - # Clean up audio state - await audio_handler.cleanup() - - # Check for pending URL from server selection first - pending_url = manager.consume_pending_url() - if pending_url: - url = pending_url - manager.reset_backoff() - ui.add_event(f"Switching to {url}...") - ui.set_disconnected(f"Switching to {url}...") - continue + next_server_task = create_task(self._discovery.wait_for_server()) - # Update URL from discovery - new_url = discovery.current_url() - - # Wait for server to reappear if it's gone - if not new_url: - ui.set_disconnected("Waiting for server...") - new_url = await manager.wait_for_server_reappear(ui) - if keyboard_task.done(): - break - - # Use the discovered URL - if new_url: - url = new_url - ui.add_event(f"Reconnecting to {url}...") - ui.set_disconnected(f"Reconnecting to {url}...") - - except (TimeoutError, OSError, ClientError) as e: - # Network-related errors - log cleanly - logger.debug( - "Connection error (%s), retrying in %.0fs", - type(e).__name__, - manager.get_error_backoff(), + if not next_server_task.done(): + await asyncio.wait( + {self._keyboard_task, next_server_task}, + return_when=asyncio.FIRST_COMPLETED, ) - if await manager.handle_error_backoff(ui): - break + if self._keyboard_task.done(): + next_server_task.cancel() + return None - # Check if URL changed while sleeping - current_url = discovery.current_url() - new_url, _ = manager.update_backoff_and_url(current_url) - if new_url: - url = new_url - except Exception: - # Unexpected errors - log with full traceback - logger.exception("Unexpected error during connection") - ui.add_event("Unexpected error occurred") - await asyncio.sleep(manager.get_error_backoff()) - manager.increase_backoff() + server = await next_server_task + return server @dataclass @@ -328,9 +226,9 @@ class AppConfig: """Configuration for the Sendspin application.""" audio_device: AudioDevice + client_id: str + client_name: str url: str | None = None - client_id: str | None = None - client_name: str | None = None static_delay_ms: float = 0.0 @@ -340,59 +238,16 @@ class SendspinApp: def __init__(self, config: AppConfig) -> None: """Initialize the application.""" self._config = config - self._ui: SendspinUI - self._state = AppState() - self._client: SendspinClient | None = None - self._audio_handler: AudioStreamHandler | None = None - self._discovery: ServiceDiscovery | None = None + self._ui = SendspinUI(config.static_delay_ms) - async def run(self) -> int: # noqa: PLR0915 - """Run the application.""" - config = self._config - - # TUI requires an interactive terminal - if not sys.stdin.isatty(): - print( # noqa: T201 - "Error: TUI mode requires an interactive terminal.\n" - "Use 'sendspin daemon' for non-interactive/background operation." - ) - return 1 - - # In interactive mode with UI, suppress logs to avoid interfering with display - # Only show WARNING and above unless explicitly set to DEBUG - if logging.getLogger().level != logging.DEBUG: - logging.getLogger().setLevel(logging.WARNING) - - # Create and start UI early so add_event works - self._ui = SendspinUI() - self._ui.start() - - try: - return await self._run_with_ui(config) - finally: - self._ui.stop() - - async def _run_with_ui(self, config: AppConfig) -> int: - """Run the application with UI already started.""" - # Get hostname for defaults if needed - client_id = config.client_id - client_name = config.client_name - if client_id is None or client_name is None: - hostname = socket.gethostname() - if not hostname: - logger.error("Unable to determine hostname. Please specify --id and/or --name") - return 1 - # Auto-generate client ID and name from hostname - if client_id is None: - client_id = f"sendspin-cli-{hostname}" - if client_name is None: - client_name = hostname - - self._ui.add_event(f"Using client ID: {client_id}") + server: DiscoveredServer | None = None + if config.url: + server = DiscoveredServer.from_url("Command-line argument", config.url) + self._state = AppState(selected_server=server) self._client = SendspinClient( - client_id=client_id, - client_name=client_name, + client_id=config.client_id, + client_name=config.client_name, roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA], device_info=get_device_info(), player_support=ClientHelloPlayerSupport( @@ -410,63 +265,41 @@ async def _run_with_ui(self, config: AppConfig) -> int: static_delay_ms=config.static_delay_ms, ) - # Start service discovery + self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._discovery = ServiceDiscovery() - await self._discovery.start() + self._connection_manager = ConnectionManager(self._discovery) - try: - # Get initial server URL - url = config.url - if url is None: - logger.info("Waiting for mDNS discovery of Sendspin server...") - self._ui.add_event("Searching for Sendspin server...") - try: - url = await self._discovery.wait_for_first_server() - logger.info("Discovered Sendspin server at %s", url) - self._ui.add_event(f"Found server at {url}") - except asyncio.CancelledError: - # When KeyboardInterrupt occurs during discovery - return 1 - except Exception: - logger.exception("Failed to discover server") - return 1 - - # Log audio device being used - logger.info( - "Using audio device %d: %s", - config.audio_device.index, - config.audio_device.name, + async def run(self) -> int: # noqa: PLR0915 + """Run the application.""" + config = self._config + + # TUI requires an interactive terminal + if not sys.stdin.isatty(): + print( # noqa: T201 + "Error: TUI mode requires an interactive terminal.\n" + "Use 'sendspin daemon' for non-interactive/background operation." ) - self._ui.add_event(f"Using audio device: {config.audio_device.name}") + return 1 - listeners = ClientListenerManager() + # In interactive mode with UI, suppress logs to avoid interfering with display + # Only show WARNING and above unless explicitly set to DEBUG + if logging.getLogger().level != logging.DEBUG: + logging.getLogger().setLevel(logging.WARNING) - self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) - self._audio_handler.attach_client(self._client, listeners) + try: + self._ui.start() + self._ui.add_event(f"Using client ID: {config.client_id}") + self._ui.add_event(f"Using audio device: {config.audio_device.name}") - # Set delay now that client is created - self._ui.set_delay(self._client.static_delay_ms) + await self._discovery.start() - self._setup_listeners(listeners) + listeners = ClientListenerManager() + listeners.add_metadata_listener(self._handle_metadata_update) + listeners.add_group_update_listener(self._handle_group_update) + listeners.add_controller_state_listener(self._handle_server_state) + listeners.add_server_command_listener(self._handle_server_command) + self._audio_handler.attach_client(self._client, listeners) listeners.attach(self._client) - loop = asyncio.get_running_loop() - - # Forward declaration for on_server_selected closure - connection_manager: ConnectionManager | None = None - - def get_servers() -> list[tuple[str, str, str, int]]: - """Get available servers from discovery.""" - if self._discovery is None: - return [] - return [(s.name, s.url, s.host, s.port) for s in self._discovery.get_servers()] - - async def on_server_selected(new_url: str) -> None: - """Handle server selection by triggering reconnect.""" - if connection_manager is None or self._client is None: - return - connection_manager.set_pending_url(new_url) - # Force disconnect to trigger reconnect with new URL - await self._client.disconnect() # Start keyboard loop for interactive control keyboard_task = create_task( @@ -475,41 +308,41 @@ async def on_server_selected(new_url: str) -> None: self._state, self._audio_handler, self._ui, - get_servers, - on_server_selected, + self._show_server_selector, + self._on_server_selected, ) ) - connection_manager = ConnectionManager(self._discovery, keyboard_task) + self._connection_manager.set_keyboard_task(keyboard_task) def signal_handler() -> None: logger.debug("Received interrupt signal, shutting down...") keyboard_task.cancel() # Signal handlers aren't supported on this platform (e.g., Windows) + loop = asyncio.get_running_loop() with contextlib.suppress(NotImplementedError): loop.add_signal_handler(signal.SIGINT, signal_handler) loop.add_signal_handler(signal.SIGTERM, signal_handler) + # Get initial server URL + url = config.url + if url is None: + logger.info("Waiting for mDNS discovery of Sendspin server...") + self._ui.add_event("Searching for Sendspin server...") + server = await self._connection_manager.discover_server() + if server is None: + return 0 + self._state.selected_server = server + self._ui.add_event(f"Found server at {server.url}") + # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - keyboard_task, - connection_manager, - self._ui, - ) + await self._connection_loop(keyboard_task) except asyncio.CancelledError: logger.debug("Connection loop cancelled") finally: - # Remove signal handlers - with contextlib.suppress(NotImplementedError): - loop.remove_signal_handler(signal.SIGINT) - loop.remove_signal_handler(signal.SIGTERM) - if self._audio_handler is not None: - await self._audio_handler.cleanup() + self._ui.stop() + await self._audio_handler.cleanup() await self._client.disconnect() await self._discovery.stop() @@ -523,122 +356,207 @@ def signal_handler() -> None: return 0 - def _setup_listeners(self, listeners: ClientListenerManager) -> None: - """Set up client event listeners.""" - assert self._client is not None + async def _connection_loop(self, keyboard_task: asyncio.Task[None]) -> None: + """ + Run the connection loop with automatic reconnection on disconnect. + + Connects to the server, waits for disconnect, cleans up, then retries + only if the server is visible via mDNS. Reconnects immediately when + server reappears. Uses exponential backoff (up to 5 min) for errors. + + Args: + keyboard_task: Keyboard input task to monitor. + """ + assert self._state.selected_server + manager = self._connection_manager + ui = self._ui client = self._client - loop = asyncio.get_running_loop() + audio_handler = self._audio_handler + discovery = self._discovery + url = self._state.selected_server.url + manager.set_last_attempted_url(url) + + while not keyboard_task.done(): + try: + await self._client.connect(url) + ui.add_event(f"Connected to {url}") + ui.set_connected(url) + manager.reset_backoff() + manager.set_last_attempted_url(url) + + # Wait for disconnect or keyboard exit + disconnect_event: asyncio.Event = asyncio.Event() + client.set_disconnect_listener(partial(asyncio.Event.set, disconnect_event)) + done, _ = await asyncio.wait( + {keyboard_task, create_task(disconnect_event.wait())}, + return_when=asyncio.FIRST_COMPLETED, + ) - listeners.add_metadata_listener( - lambda payload: _handle_metadata_update(self._state, self._ui, payload) - ) - listeners.add_group_update_listener( - lambda payload: _handle_group_update(self._state, self._ui, payload) - ) - listeners.add_controller_state_listener( - lambda payload: _handle_server_state(self._state, self._ui, payload) - ) - listeners.add_server_command_listener( - lambda payload: _handle_server_command(self._state, client, self._ui, payload, loop) - ) + client.set_disconnect_listener(None) + if keyboard_task in done: + break + + # Connection dropped + logger.info("Connection lost") + ui.add_event("Connection lost") + ui.set_disconnected("Connection lost") + + # Clean up audio state + await audio_handler.cleanup() + + # Check for pending URL from server selection first + pending_server = manager.consume_pending_server() + if pending_server: + self._state.selected_server = pending_server + url = pending_server.url + manager.reset_backoff() + ui.add_event(f"Switching to {url}...") + ui.set_disconnected(f"Switching to {url}...") + continue + + # Update URL from discovery + server = servers[0] if (servers := discovery.get_servers()) else None + + # Wait for server to reappear if it's gone + if not server: + ui.set_disconnected("Waiting for server...") + logger.info("Server offline, waiting for rediscovery...") + ui.add_event("Waiting for server...") + + server = await manager.discover_server() + if not server: + break + + self._state.selected_server = server + url = server.url + ui.add_event(f"Reconnecting to {url}...") + ui.set_disconnected(f"Reconnecting to {url}...") + + except (TimeoutError, OSError, ClientError) as e: + # Network-related errors - log cleanly + logger.debug( + "Connection error (%s), retrying in %.0fs", + type(e).__name__, + manager.get_error_backoff(), + ) + + if await manager.handle_error_backoff(ui): + break + # Check if URL changed while sleeping + if servers := discovery.get_servers(): + current_url = servers[0].url + new_url, _ = manager.update_backoff_and_url(current_url) + if new_url: + url = new_url + except Exception: + # Unexpected errors - log with full traceback + logger.exception("Unexpected error") + break -def _handle_metadata_update( - state: AppState, - ui: SendspinUI, - payload: ServerStatePayload, -) -> None: - """Handle server/state messages with metadata.""" - if payload.metadata is not None and state.update_metadata(payload.metadata): - ui.set_metadata( - title=state.title, - artist=state.artist, - album=state.album, + def _show_server_selector(self) -> None: + servers = self._discovery.get_servers() + if self._state.selected_server and self._state.selected_server not in servers: + servers.insert(0, self._state.selected_server) + self._ui.show_server_selector(servers) + + async def _on_server_selected(self) -> None: + """Handle server selection by triggering reconnect.""" + server = self._ui.get_selected_server() + if server is None: + return + + self._ui.hide_server_selector() + # Skip reconnection if already connected to this server + if server == self._state.selected_server: + return + + self._connection_manager.set_pending_server(server) + # Force disconnect to trigger reconnect with new URL + await self._client.disconnect() + + def _handle_metadata_update(self, payload: ServerStatePayload) -> None: + """Handle server/state messages with metadata.""" + state = self._state + ui = self._ui + if payload.metadata is not None and state.update_metadata(payload.metadata): + ui.set_metadata( + title=state.title, + artist=state.artist, + album=state.album, + ) + ui.set_progress(state.track_progress, state.track_duration) + ui.add_event(state.describe()) + + def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None: + """Handle group update messages.""" + state = self._state + ui = self._ui + # Only clear metadata when actually switching to a different group + group_changed = payload.group_id is not None and payload.group_id != state.group_id + if group_changed: + state.group_id = payload.group_id + state.title = None + state.artist = None + state.album = None + state.track_progress = None + state.track_duration = None + ui.set_metadata(title=None, artist=None, album=None) + ui.clear_progress() + ui.add_event(f"Group ID: {payload.group_id}") + + if payload.group_name: + ui.add_event(f"Group name: {payload.group_name}") + ui.set_group_name(payload.group_name) + if payload.playback_state: + state.playback_state = payload.playback_state + ui.set_playback_state(payload.playback_state) + ui.add_event(f"Playback state: {payload.playback_state.value}") + + def _handle_server_state(self, payload: ServerStatePayload) -> None: + """Handle server/state messages with controller state.""" + state = self._state + ui = self._ui + if payload.controller: + controller = payload.controller + state.supported_commands = set(controller.supported_commands) + + volume_changed = controller.volume != state.volume + mute_changed = controller.muted != state.muted + + if volume_changed: + state.volume = controller.volume + ui.add_event(f"Volume: {controller.volume}%") + if mute_changed: + state.muted = controller.muted + ui.add_event("Muted" if controller.muted else "Unmuted") + + if volume_changed or mute_changed: + ui.set_volume(state.volume, muted=state.muted) + + def _handle_server_command(self, payload: ServerCommandPayload) -> None: + """Handle server/command messages for player volume/mute control.""" + if payload.player is None: + return + + state = self._state + ui = self._ui + player_cmd: PlayerCommandPayload = payload.player + + if player_cmd.command == PlayerCommand.VOLUME and player_cmd.volume is not None: + state.player_volume = player_cmd.volume + ui.set_player_volume(state.player_volume, muted=state.player_muted) + ui.add_event(f"Server set player volume: {player_cmd.volume}%") + elif player_cmd.command == PlayerCommand.MUTE and player_cmd.mute is not None: + state.player_muted = player_cmd.mute + ui.set_player_volume(state.player_volume, muted=state.player_muted) + ui.add_event("Server muted player" if player_cmd.mute else "Server unmuted player") + + # Send state update back to server per spec + create_task( + self._client.send_player_state( + state=PlayerStateType.SYNCHRONIZED, + volume=state.player_volume, + muted=state.player_muted, + ) ) - ui.set_progress(state.track_progress, state.track_duration) - ui.add_event(state.describe()) - - -def _handle_group_update( - state: AppState, - ui: SendspinUI, - payload: GroupUpdateServerPayload, -) -> None: - """Handle group update messages.""" - # Only clear metadata when actually switching to a different group - group_changed = payload.group_id is not None and payload.group_id != state.group_id - if group_changed: - state.group_id = payload.group_id - state.title = None - state.artist = None - state.album = None - state.track_progress = None - state.track_duration = None - ui.set_metadata(title=None, artist=None, album=None) - ui.clear_progress() - ui.add_event(f"Group ID: {payload.group_id}") - - if payload.group_name: - ui.add_event(f"Group name: {payload.group_name}") - ui.set_group_name(payload.group_name) - if payload.playback_state: - state.playback_state = payload.playback_state - ui.set_playback_state(payload.playback_state) - ui.add_event(f"Playback state: {payload.playback_state.value}") - - -def _handle_server_state( - state: AppState, - ui: SendspinUI, - payload: ServerStatePayload, -) -> None: - """Handle server/state messages with controller state.""" - if payload.controller: - controller = payload.controller - state.supported_commands = set(controller.supported_commands) - - volume_changed = controller.volume != state.volume - mute_changed = controller.muted != state.muted - - if volume_changed: - state.volume = controller.volume - ui.add_event(f"Volume: {controller.volume}%") - if mute_changed: - state.muted = controller.muted - ui.add_event("Muted" if controller.muted else "Unmuted") - - if volume_changed or mute_changed: - ui.set_volume(state.volume, muted=state.muted) - - -def _handle_server_command( - state: AppState, - client: SendspinClient, - ui: SendspinUI, - payload: ServerCommandPayload, - loop: asyncio.AbstractEventLoop, -) -> None: - """Handle server/command messages for player volume/mute control.""" - if payload.player is None: - return - - player_cmd: PlayerCommandPayload = payload.player - - if player_cmd.command == PlayerCommand.VOLUME and player_cmd.volume is not None: - state.player_volume = player_cmd.volume - ui.set_player_volume(state.player_volume, muted=state.player_muted) - ui.add_event(f"Server set player volume: {player_cmd.volume}%") - elif player_cmd.command == PlayerCommand.MUTE and player_cmd.mute is not None: - state.player_muted = player_cmd.mute - ui.set_player_volume(state.player_volume, muted=state.player_muted) - ui.add_event("Server muted player" if player_cmd.mute else "Server unmuted player") - - # Send state update back to server per spec - create_task( - client.send_player_state( - state=PlayerStateType.SYNCHRONIZED, - volume=state.player_volume, - muted=state.player_muted, - ), - loop=loop, - ) diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 0f3f47c..48c7f09 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -4,15 +4,12 @@ import asyncio import logging -import sys from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING import readchar from aiosendspin.models.types import MediaCommand, PlaybackStateType, PlayerStateType -from sendspin.tui.ui import DiscoveredServerInfo - if TYPE_CHECKING: from aiosendspin.client import SendspinClient @@ -32,16 +29,12 @@ def __init__( state: AppState, audio_handler: AudioStreamHandler, ui: SendspinUI, - get_servers: Callable[[], list[tuple[str, str, str, int]]], - on_server_selected: Callable[[str], Awaitable[None]], ) -> None: """Initialize the command handler.""" self._client = client self._state = state self._audio_handler = audio_handler self._ui = ui - self._get_servers = get_servers - self._on_server_selected = on_server_selected async def send_media_command(self, command: MediaCommand) -> None: """Send a media command with validation.""" @@ -95,36 +88,18 @@ async def adjust_delay(self, delta: float) -> None: self._client.set_static_delay_ms(self._client.static_delay_ms + delta) self._ui.set_delay(self._client.static_delay_ms) - def open_server_selector(self) -> None: - """Open the server selector panel.""" - server_tuples = self._get_servers() - servers = [ - DiscoveredServerInfo(name=name, url=url, host=host, port=port) - for name, url, host, port in server_tuples - ] - self._ui.show_server_selector(servers) - def close_server_selector(self) -> None: """Close the server selector panel.""" self._ui.hide_server_selector() - async def select_server(self) -> None: - """Select the highlighted server and connect to it.""" - server = self._ui.get_selected_server() - if server is not None: - self._ui.hide_server_selector() - # Skip reconnection if already connected to this server - if server.url != self._ui.state.server_url: - await self._on_server_selected(server.url) - async def keyboard_loop( client: SendspinClient, state: AppState, audio_handler: AudioStreamHandler, ui: SendspinUI, - get_servers: Callable[[], list[tuple[str, str, str, int]]], - on_server_selected: Callable[[str], Awaitable[None]], + show_server_selector: Callable[[], None], + on_server_selected: Callable[[], Awaitable[None]], ) -> None: """Run the keyboard input loop. @@ -136,7 +111,7 @@ async def keyboard_loop( get_servers: Function that returns list of (name, url, host, port) tuples. on_server_selected: Async callback when a server is selected (receives URL). """ - handler = CommandHandler(client, state, audio_handler, ui, get_servers, on_server_selected) + handler = CommandHandler(client, state, audio_handler, ui) # Key dispatch table: key -> (highlight_name | None, async action) # For keys that need case-insensitive matching, use lowercase @@ -161,11 +136,6 @@ async def keyboard_loop( readchar.key.DOWN: ("down", lambda: handler.change_player_volume(-5)), } - if not sys.stdin.isatty(): - logger.info("Running as daemon without interactive input") - await asyncio.Event().wait() - return - # Interactive mode with single keypress input using readchar loop = asyncio.get_running_loop() @@ -176,12 +146,11 @@ async def keyboard_loop( except (asyncio.CancelledError, KeyboardInterrupt): break - # Handle Ctrl+C - if key == "\x03": - break - # Handle server selector mode - if ui is not None and ui.is_server_selector_visible(): + if ui.is_server_selector_visible(): + if key in "rR": + show_server_selector() + continue if key == readchar.key.UP: ui.highlight_shortcut("selector-up") ui.move_server_selection(-1) @@ -192,22 +161,23 @@ async def keyboard_loop( continue if key in ("\r", "\n", readchar.key.ENTER): ui.highlight_shortcut("selector-enter") - await handler.select_server() + await on_server_selected() + continue + if key in "qQ": + ui.hide_server_selector() continue # Ignore other keys when selector is open continue # Handle quit - if key in "q": - if ui: - ui.highlight_shortcut("quit") + if key in "qQ": + ui.highlight_shortcut("quit") break # Handle 's' to open server selector if key in "sS": - if ui: - ui.highlight_shortcut("server") - handler.open_server_selector() + ui.highlight_shortcut("server") + show_server_selector() continue # Handle shortcuts via dispatch table (case-insensitive for letter keys) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 0ee5535..47424b1 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -13,6 +13,8 @@ from rich.table import Table from rich.text import Text +from sendspin.discovery import DiscoveredServer + class _RefreshableLayout: """A renderable that rebuilds on each render cycle.""" @@ -29,16 +31,6 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR SHORTCUT_HIGHLIGHT_DURATION = 0.15 -@dataclass -class DiscoveredServerInfo: - """Information about a discovered server for display.""" - - name: str - url: str - host: str - port: int - - @dataclass class UIState: """Holds state for the UI display.""" @@ -51,7 +43,7 @@ class UIState: # Server selector show_server_selector: bool = False - available_servers: list[DiscoveredServerInfo] = field(default_factory=list) + available_servers: list[DiscoveredServer] = field(default_factory=list) selected_server_index: int = 0 # Playback @@ -80,10 +72,10 @@ class UIState: class SendspinUI: """Rich-based terminal UI for the Sendspin CLI.""" - def __init__(self) -> None: + def __init__(self, delay_ms: float) -> None: """Initialize the UI.""" self._console = Console() - self._state = UIState() + self._state = UIState(delay_ms=delay_ms) self._live: Live | None = None self._running = False @@ -322,7 +314,11 @@ def _build_server_selector_panel(self) -> Panel: shortcuts.append("↓", style=self._shortcut_style("selector-down")) shortcuts.append(" navigate ", style="dim") shortcuts.append("", style=self._shortcut_style("selector-enter")) - shortcuts.append(" connect", style="dim") + shortcuts.append(" connect ", style="dim") + shortcuts.append("r", style=self._shortcut_style("selector-enter")) + shortcuts.append(" refresh ", style="dim") + shortcuts.append("q", style=self._shortcut_style("selector-enter")) + shortcuts.append(" back", style="dim") content.add_row(shortcuts) return Panel(content, title="Select Server", border_style="cyan") @@ -491,7 +487,7 @@ def set_delay(self, delay_ms: float) -> None: self._state.delay_ms = delay_ms self.refresh() - def show_server_selector(self, servers: list[DiscoveredServerInfo]) -> None: + def show_server_selector(self, servers: list[DiscoveredServer]) -> None: """Show the server selector with available servers.""" self._state.available_servers = servers self._state.selected_server_index = 0 @@ -517,7 +513,7 @@ def move_server_selection(self, delta: int) -> None: ) self.refresh() - def get_selected_server(self) -> DiscoveredServerInfo | None: + def get_selected_server(self) -> DiscoveredServer | None: """Get the currently selected server.""" if not self._state.available_servers: return None diff --git a/sendspin/utils.py b/sendspin/utils.py index 78d0322..f2e79ef 100644 --- a/sendspin/utils.py +++ b/sendspin/utils.py @@ -8,7 +8,7 @@ from collections.abc import Coroutine from importlib.metadata import version from pathlib import Path -from typing import TypeVar +from typing import Any, TypeVar from aiosendspin.models.core import DeviceInfo @@ -17,6 +17,8 @@ # Check if eager_start is supported (Python 3.12+) _SUPPORTS_EAGER_START = sys.version_info >= (3, 12) +TASKS: set[asyncio.Task[Any]] = set() + def create_task( coro: Coroutine[None, None, _T], @@ -49,9 +51,18 @@ def create_task( if _SUPPORTS_EAGER_START and eager_start: # Use Task constructor directly - it supports eager_start and schedules automatically - return asyncio.Task(coro, loop=loop, name=name, eager_start=True) + task = asyncio.Task(coro, loop=loop, name=name, eager_start=True) + else: + task = loop.create_task(coro, name=name) + + if task.done(): + return task + + TASKS.add(task) + task.add_done_callback(TASKS.discard) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) - return loop.create_task(coro, name=name) + return task def get_device_info() -> DeviceInfo: