From 0706d6a31fbe88732ae35712c42f8e4f9d194551 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 00:20:44 +0000 Subject: [PATCH 01/11] Split headless mode into separate daemon command - Create new daemon.py module for headless operation - Add 'daemon' subcommand to CLI (sendspin daemon) - Deprecate --headless flag with warning, route to daemon mode - Remove headless branching from app.py (TUI mode only) - Update CLAUDE.md with new architecture diagram and file responsibilities - Daemon and TUI modes now cleanly separated for independent evolution Breaking change: Recommended to use 'sendspin daemon' instead of --headless --- CLAUDE.md | 89 +++++++++++------- sendspin/app.py | 33 +++---- sendspin/cli.py | 100 +++++++++++++++++++- sendspin/daemon.py | 224 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 55 deletions(-) create mode 100644 sendspin/daemon.py diff --git a/CLAUDE.md b/CLAUDE.md index 7e25ba1..c174395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,34 +7,39 @@ Sendspin CLI is a synchronized audio player client for the [Sendspin Protocol](h ## Architecture ``` -┌─────────────────────────────────────────────────────────────────┐ -│ cli.py (main) │ -│ - Entry point, argument parsing, async event loop │ -│ - mDNS service discovery │ -│ - Connection management with auto-reconnect │ -│ - State management (CLIState) │ -│ - Coordinates all other modules │ -└─────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ keyboard.py │ │ ui.py │ │ audio.py │ -│ │ │ │ │ │ -│ - Key capture │ │ - Rich TUI │ │ - Audio output │ -│ - Command parse │ │ - Now Playing │ │ - Time sync │ -│ - Media control │ │ - Volume panel │ │ - Buffer mgmt │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ │ - └────────────────────┼────────────────────┘ - ▼ - ┌─────────────────────┐ - │ aiosendspin │ - │ (external library) │ - │ │ - │ - WebSocket client │ - │ - Protocol messages │ - │ - Time sync filter │ - └─────────────────────┘ + cli.py (main entry) + │ + ┌───────────────┼───────────────┐ + │ │ + ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────┐ + │ app.py (TUI) │ │ daemon.py (no UI) │ + │ │ │ │ + │ - Interactive mode │ │ - Headless mode │ + │ - Keyboard control │ │ - Signal handlers │ + │ - Rich UI display │ │ - Print events │ + └─────────────────────┘ └─────────────────────┘ + │ │ + ┌───────────┼───────────┐ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│keyboard.py │ │ ui.py │ │audio.py │ │audio.py │ +│ │ │ │ │ │ │ │ +│ - Keys │ │ - TUI │ │ - Sync │ │ - Sync │ +│ - Commands │ │ - Panel │ │ - Output│ │ - Output│ +└────────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ + └───────────┼───────────┴───────────────────┘ + ▼ + ┌─────────────────────┐ + │ aiosendspin │ + │ (external library) │ + │ │ + │ - WebSocket client │ + │ - Protocol messages │ + │ - Time sync filter │ + └─────────────────────┘ ``` ## File Responsibilities @@ -44,12 +49,28 @@ Project documentation, installation instructions, usage guide (including command ### `sendspin/cli.py` Main entry point and orchestrator. Responsibilities: -- **Argument parsing**: `--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`, etc. -- **Service discovery**: mDNS discovery of Sendspin servers via `ServiceDiscovery` class +- **Argument parsing**: Commands (`serve`, `daemon`), flags (`--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`) +- **Command routing**: Routes to appropriate mode (TUI app, daemon, or server) +- **Device enumeration**: Lists audio devices and servers via `--list-audio-devices` and `--list-servers` +- **Backward compatibility**: Handles deprecated `--headless` flag with warning + +### `sendspin/app.py` +Interactive TUI mode (default). Responsibilities: +- **Service discovery**: mDNS discovery of Sendspin servers - **Connection management**: `ConnectionManager` handles reconnection with exponential backoff -- **State management**: `CLIState` dataclass mirrors server state (playback, metadata, volume) +- **State management**: `AppState` dataclass mirrors server state (playback, metadata, volume) - **Audio stream handling**: `AudioStreamHandler` bridges between client and `AudioPlayer` - **Event callbacks**: Routes server messages to appropriate handlers (metadata, group updates, commands) +- **UI coordination**: Manages `SendspinUI` and keyboard input loop + +### `sendspin/daemon.py` +Daemon mode for headless operation (via `sendspin daemon` or deprecated `--headless`). Responsibilities: +- **Headless operation**: Runs without UI or keyboard input +- **Service discovery**: Same mDNS discovery as TUI mode +- **Connection management**: Uses same `ConnectionManager` from `app.py` +- **Audio playback**: Same synchronized audio playback +- **Event logging**: Prints events to stdout instead of UI +- **Signal handling**: SIGINT/SIGTERM for graceful shutdown ### `sendspin/keyboard.py` Keyboard input handling for interactive control. Responsibilities: @@ -94,8 +115,10 @@ Package entry point, exports `main` from `cli.py`. ### Running the player ```bash -uv run sendspin # Auto-discover server -uv run sendspin --url ws://host:port/sendspin # Direct connection +uv run sendspin # TUI mode with auto-discovery +uv run sendspin --url ws://host:port/sendspin # TUI mode with direct connection +uv run sendspin daemon # Daemon mode (headless) with auto-discovery +uv run sendspin daemon --url ws://host:port/sendspin # Daemon mode with direct connection ``` ### Development commands diff --git a/sendspin/app.py b/sendspin/app.py index a9d132e..fc2194a 100644 --- a/sendspin/app.py +++ b/sendspin/app.py @@ -390,7 +390,6 @@ class AppConfig: client_id: str | None = None client_name: str | None = None static_delay_ms: float = 0.0 - headless: bool = False class SendspinApp: @@ -491,8 +490,8 @@ async def run(self) -> int: # noqa: PLR0915 self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._audio_handler.attach_client(self._client, listeners) - # Create UI for interactive mode (unless headless) - if sys.stdin.isatty() and not config.headless: + # Create UI for interactive mode + if sys.stdin.isatty(): self._ui = SendspinUI() self._ui.start() self._ui.set_delay(self._client.static_delay_ms) @@ -521,24 +520,18 @@ async def on_server_selected(new_url: str) -> None: # Force disconnect to trigger reconnect with new URL await self._client.disconnect() - async def wait_forever() -> None: - await asyncio.Event().wait() - - if config.headless: - # In headless mode, just wait for cancellation - keyboard_task = create_task(wait_forever()) - else: - keyboard_task = create_task( - keyboard_loop( - self._client, - self._state, - self._audio_handler, - self._ui, - self._print_event, - get_servers, - on_server_selected, - ) + # Start keyboard loop for interactive control + keyboard_task = create_task( + keyboard_loop( + self._client, + self._state, + self._audio_handler, + self._ui, + self._print_event, + get_servers, + on_server_selected, ) + ) connection_manager = ConnectionManager(self._discovery, keyboard_task) diff --git a/sendspin/cli.py b/sendspin/cli.py index bacd593..aa423c0 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -81,6 +81,45 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: help="Logging level to use", ) + # Daemon subcommand + daemon_parser = subparsers.add_parser("daemon", help="Run Sendspin client in daemon mode (no UI)") + daemon_parser.add_argument( + "--url", + default=None, + help=("WebSocket URL of the Sendspin server. If omitted, discover via mDNS."), + ) + daemon_parser.add_argument( + "--name", + default=None, + help="Friendly name for this client (defaults to hostname)", + ) + daemon_parser.add_argument( + "--id", + default=None, + help="Unique identifier for this client (defaults to sendspin-cli-)", + ) + daemon_parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging level to use", + ) + daemon_parser.add_argument( + "--static-delay-ms", + type=float, + default=0.0, + help="Extra playback delay in milliseconds applied after clock sync", + ) + daemon_parser.add_argument( + "--audio-device", + type=str, + default=None, + help=( + "Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). " + "Use --list-audio-devices to see available devices." + ), + ) + # Default behavior (client mode) - existing arguments parser.add_argument( "--url", @@ -131,7 +170,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument( "--headless", action="store_true", - help="Run without the interactive terminal UI", + help="(DEPRECATED: use 'sendspin daemon' instead) Run without the interactive terminal UI", ) return parser.parse_args(argv) @@ -159,6 +198,54 @@ async def list_servers() -> None: sys.exit(1) +def _run_daemon_mode(args: argparse.Namespace) -> int: + """Run the client in daemon mode (no UI).""" + try: + from sendspin.audio import query_devices + from sendspin.daemon import DaemonConfig, SendspinDaemon + except OSError as e: + if "PortAudio library not found" in str(e): + print(PORTAUDIO_NOT_FOUND_MESSAGE) + return 1 + raise + + # Resolve audio device if specified + audio_device = None + devices = query_devices() + if args.audio_device is None: + audio_device = next((d for d in devices if d.is_default), None) + elif args.audio_device.isnumeric(): + device_id = int(args.audio_device) + for dev in devices: + if dev.index == device_id: + audio_device = dev + break + else: + # Otherwise, find first output device whose name starts with the prefix + for dev in devices: + if dev.name.startswith(args.audio_device): + audio_device = dev + break + + if audio_device is None: + dev_type = "Default" if args.audio_device is None else "Specified" + print(f"Error: {dev_type} audio device not found.") + return 1 + + # Create config from CLI arguments + daemon_config = DaemonConfig( + url=args.url, + client_id=args.id, + client_name=args.name, + static_delay_ms=args.static_delay_ms, + audio_device=audio_device, + ) + + # Run the daemon + daemon = SendspinDaemon(daemon_config) + return asyncio.run(daemon.run()) + + def main() -> int: """Run the CLI client.""" import logging @@ -197,6 +284,10 @@ def main() -> int: traceback.print_exc() return 1 + # Handle daemon subcommand + if args.command == "daemon": + return _run_daemon_mode(args) + # Handle --list-audio-devices before starting async runtime if args.list_audio_devices: list_audio_devices() @@ -206,6 +297,12 @@ def main() -> int: asyncio.run(list_servers()) return 0 + # 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) + try: from sendspin.audio import query_devices from sendspin.app import AppConfig, SendspinApp @@ -245,7 +342,6 @@ def main() -> int: client_name=args.name, static_delay_ms=args.static_delay_ms, audio_device=audio_device, - headless=args.headless, ) # Run the application diff --git a/sendspin/daemon.py b/sendspin/daemon.py new file mode 100644 index 0000000..635378d --- /dev/null +++ b/sendspin/daemon.py @@ -0,0 +1,224 @@ +"""Daemon mode for running Sendspin client without UI.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import signal +import socket +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +from aiosendspin.client import SendspinClient +from aiosendspin.models.player import ( + ClientHelloPlayerSupport, + PlayerCommand, + SupportedAudioFormat, +) +from aiosendspin.models.types import ( + AudioCodec, + Roles, +) + +from sendspin.app import ( + AppState, + ConnectionManager, + connection_loop, + get_device_info, +) +from sendspin.audio import AudioDevice +from sendspin.audio_connector import AudioStreamHandler +from sendspin.client_listeners import ClientListenerManager +from sendspin.discovery import ServiceDiscovery +from sendspin.utils import create_task + +logger = logging.getLogger(__name__) + + +@dataclass +class DaemonConfig: + """Configuration for the Sendspin daemon.""" + + audio_device: AudioDevice + url: str | None = None + client_id: str | None = None + client_name: str | None = None + static_delay_ms: float = 0.0 + + +class SendspinDaemon: + """Sendspin daemon - headless mode without UI.""" + + def __init__(self, config: DaemonConfig) -> None: + """Initialize the daemon.""" + self._config = config + self._state = AppState() + self._client: SendspinClient | None = None + self._audio_handler: AudioStreamHandler | None = None + self._discovery: ServiceDiscovery | None = None + + def _print_event(self, message: str) -> None: + """Print an event message.""" + print(message, flush=True) # noqa: T201 + + 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 + + self._print_event(f"Using client ID: {client_id}") + + self._client = SendspinClient( + client_id=client_id, + client_name=client_name, + roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA], + device_info=get_device_info(), + player_support=ClientHelloPlayerSupport( + supported_formats=[ + SupportedAudioFormat( + codec=AudioCodec.PCM, channels=2, sample_rate=44_100, bit_depth=16 + ), + SupportedAudioFormat( + codec=AudioCodec.PCM, channels=1, sample_rate=44_100, bit_depth=16 + ), + ], + buffer_capacity=32_000_000, + supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], + ), + static_delay_ms=config.static_delay_ms, + ) + + # Start service discovery + self._discovery = ServiceDiscovery() + 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...") + self._print_event("Searching for Sendspin server...") + try: + url = await self._discovery.wait_for_first_server() + logger.info("Discovered Sendspin server at %s", url) + self._print_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, + ) + self._print_event(f"Using audio device: {config.audio_device.name}") + + listeners = ClientListenerManager() + + self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) + self._audio_handler.attach_client(self._client, listeners) + + self._setup_listeners(listeners) + listeners.attach(self._client) + loop = asyncio.get_running_loop() + + try: + # Wait forever task for daemon mode - just wait for cancellation + async def wait_forever() -> None: + await asyncio.Event().wait() + + daemon_task = create_task(wait_forever()) + connection_manager = ConnectionManager(self._discovery, daemon_task) + + def signal_handler() -> None: + logger.debug("Received interrupt signal, shutting down...") + daemon_task.cancel() + + # Signal handlers aren't supported on this platform (e.g., Windows) + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) + + try: + # Run connection loop with auto-reconnect + await connection_loop( + self._client, + self._discovery, + self._audio_handler, + url, + daemon_task, + self._print_event, + connection_manager, + ui=None, # No UI in daemon mode + ) + except asyncio.CancelledError: + logger.debug("Connection loop cancelled") + finally: + # Remove signal handlers + # Signal handlers aren't supported on this platform (e.g., Windows) + 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() + + finally: + pass # No additional cleanup needed for inner try block + + finally: + # Stop discovery + await self._discovery.stop() + + return 0 + + def _setup_listeners(self, listeners: ClientListenerManager) -> None: + """Set up client event listeners.""" + from sendspin.app import ( + _handle_group_update, + _handle_metadata_update, + _handle_server_command, + _handle_server_state, + ) + + assert self._client is not None + client = self._client + loop = asyncio.get_running_loop() + + # Reuse the same listener handlers from app.py, but pass ui=None + listeners.add_metadata_listener( + lambda payload: _handle_metadata_update( + self._state, None, self._print_event, payload + ) + ) + listeners.add_group_update_listener( + lambda payload: _handle_group_update(self._state, None, self._print_event, payload) + ) + listeners.add_controller_state_listener( + lambda payload: _handle_server_state(self._state, None, self._print_event, payload) + ) + listeners.add_server_command_listener( + lambda payload: _handle_server_command( + self._state, client, None, self._print_event, payload, loop + ) + ) From 5fa701b943f640853e21a2333531c4d448dc974f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 00:24:28 +0000 Subject: [PATCH 02/11] Simplify nested try-finally blocks in app.py and daemon.py Flatten exception handling structure by removing unnecessary nesting: - Move signal handler setup before main try block - Combine cleanup operations in single finally block - Reduce indentation and improve code readability No functional changes, just cleaner exception handling flow. --- sendspin/app.py | 118 ++++++++++++++++++++++----------------------- sendspin/daemon.py | 69 ++++++++++++-------------- 2 files changed, 89 insertions(+), 98 deletions(-) diff --git a/sendspin/app.py b/sendspin/app.py index fc2194a..8d7ab2a 100644 --- a/sendspin/app.py +++ b/sendspin/app.py @@ -500,73 +500,69 @@ async def run(self) -> int: # noqa: PLR0915 listeners.attach(self._client) loop = asyncio.get_running_loop() - try: - # Audio player will be created when first audio chunk arrives - - # 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( - keyboard_loop( - self._client, - self._state, - self._audio_handler, - self._ui, - self._print_event, - get_servers, - on_server_selected, - ) + # 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( + keyboard_loop( + self._client, + self._state, + self._audio_handler, + self._ui, + self._print_event, + get_servers, + on_server_selected, ) + ) - connection_manager = ConnectionManager(self._discovery, keyboard_task) + connection_manager = ConnectionManager(self._discovery, keyboard_task) - def signal_handler() -> None: - logger.debug("Received interrupt signal, shutting down...") - keyboard_task.cancel() + 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) - with contextlib.suppress(NotImplementedError): - loop.add_signal_handler(signal.SIGINT, signal_handler) - loop.add_signal_handler(signal.SIGTERM, signal_handler) + # Signal handlers aren't supported on this platform (e.g., Windows) + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) - try: - # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - keyboard_task, - self._print_event, - connection_manager, - self._ui, - ) - except asyncio.CancelledError: - logger.debug("Connection loop cancelled") - finally: - # Remove signal handlers - # Signal handlers aren't supported on this platform (e.g., Windows) - 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() + try: + # Run connection loop with auto-reconnect + await connection_loop( + self._client, + self._discovery, + self._audio_handler, + url, + keyboard_task, + self._print_event, + connection_manager, + self._ui, + ) + 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) + await self._audio_handler.cleanup() + await self._client.disconnect() + # Stop UI if self._ui is not None: self._ui.stop() diff --git a/sendspin/daemon.py b/sendspin/daemon.py index 635378d..17fa224 100644 --- a/sendspin/daemon.py +++ b/sendspin/daemon.py @@ -143,48 +143,43 @@ async def run(self) -> int: listeners.attach(self._client) loop = asyncio.get_running_loop() - try: - # Wait forever task for daemon mode - just wait for cancellation - async def wait_forever() -> None: - await asyncio.Event().wait() + # Wait forever task for daemon mode - just wait for cancellation + async def wait_forever() -> None: + await asyncio.Event().wait() - daemon_task = create_task(wait_forever()) - connection_manager = ConnectionManager(self._discovery, daemon_task) + daemon_task = create_task(wait_forever()) + connection_manager = ConnectionManager(self._discovery, daemon_task) - def signal_handler() -> None: - logger.debug("Received interrupt signal, shutting down...") - daemon_task.cancel() + def signal_handler() -> None: + logger.debug("Received interrupt signal, shutting down...") + daemon_task.cancel() - # Signal handlers aren't supported on this platform (e.g., Windows) - with contextlib.suppress(NotImplementedError): - loop.add_signal_handler(signal.SIGINT, signal_handler) - loop.add_signal_handler(signal.SIGTERM, signal_handler) - - try: - # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - daemon_task, - self._print_event, - connection_manager, - ui=None, # No UI in daemon mode - ) - except asyncio.CancelledError: - logger.debug("Connection loop cancelled") - finally: - # Remove signal handlers - # Signal handlers aren't supported on this platform (e.g., Windows) - 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() + # Signal handlers aren't supported on this platform (e.g., Windows) + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) + try: + # Run connection loop with auto-reconnect + await connection_loop( + self._client, + self._discovery, + self._audio_handler, + url, + daemon_task, + self._print_event, + connection_manager, + ui=None, # No UI in daemon mode + ) + except asyncio.CancelledError: + logger.debug("Connection loop cancelled") finally: - pass # No additional cleanup needed for inner try block + # 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() finally: # Stop discovery From 015b2d7dc6e597fbc57e66b1d1f3adb03ccdea47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 00:27:58 +0000 Subject: [PATCH 03/11] Combine try-finally blocks into single level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flatten exception handling from two try-finally blocks to one: - Move discovery.stop() into the main finally block - Remove outer try-finally that only stopped discovery - All cleanup now happens in single finally block Benefits: - Simpler structure: 2 levels → 1 level of nesting - Easier to understand cleanup flow - Same cleanup guarantees, less code --- sendspin/app.py | 66 ++++++++++++++++++++++------------------------ sendspin/daemon.py | 42 +++++++++++++---------------- 2 files changed, 50 insertions(+), 58 deletions(-) diff --git a/sendspin/app.py b/sendspin/app.py index 8d7ab2a..b96f5ca 100644 --- a/sendspin/app.py +++ b/sendspin/app.py @@ -541,44 +541,40 @@ def signal_handler() -> None: loop.add_signal_handler(signal.SIGINT, signal_handler) loop.add_signal_handler(signal.SIGTERM, signal_handler) - try: - # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - keyboard_task, - self._print_event, - connection_manager, - self._ui, - ) - 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) - await self._audio_handler.cleanup() - await self._client.disconnect() - - # Stop UI - if self._ui is not None: - self._ui.stop() - - # Show hint if delay was changed during session - current_delay = self._client.static_delay_ms - if current_delay != config.static_delay_ms: - print( # noqa: T201 - f"\nDelay changed to {current_delay:.0f}ms. " - f"Use '--static-delay-ms {current_delay:.0f}' next time to persist." - ) - + # Run connection loop with auto-reconnect + await connection_loop( + self._client, + self._discovery, + self._audio_handler, + url, + keyboard_task, + self._print_event, + connection_manager, + self._ui, + ) + except asyncio.CancelledError: + logger.debug("Connection loop cancelled") finally: - # Stop discovery + # 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() await self._discovery.stop() + # Stop UI + if self._ui is not None: + self._ui.stop() + + # Show hint if delay was changed during session + current_delay = self._client.static_delay_ms + if current_delay != config.static_delay_ms: + print( # noqa: T201 + f"\nDelay changed to {current_delay:.0f}ms. " + f"Use '--static-delay-ms {current_delay:.0f}' next time to persist." + ) + return 0 def _setup_listeners(self, listeners: ClientListenerManager) -> None: diff --git a/sendspin/daemon.py b/sendspin/daemon.py index 17fa224..9184f49 100644 --- a/sendspin/daemon.py +++ b/sendspin/daemon.py @@ -159,30 +159,26 @@ def signal_handler() -> None: loop.add_signal_handler(signal.SIGINT, signal_handler) loop.add_signal_handler(signal.SIGTERM, signal_handler) - try: - # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - daemon_task, - self._print_event, - connection_manager, - ui=None, # No UI in daemon mode - ) - 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) - await self._audio_handler.cleanup() - await self._client.disconnect() - + # Run connection loop with auto-reconnect + await connection_loop( + self._client, + self._discovery, + self._audio_handler, + url, + daemon_task, + self._print_event, + connection_manager, + ui=None, # No UI in daemon mode + ) + except asyncio.CancelledError: + logger.debug("Connection loop cancelled") finally: - # Stop discovery + # 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() await self._discovery.stop() return 0 From 55ef9f03ba6a72a34cc80199b52e30d3b34df9a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 00:34:45 +0000 Subject: [PATCH 04/11] Organize code into tui/ and daemon/ package directories Directory structure: - sendspin/tui/ - TUI mode (app.py, ui.py, keyboard.py) - sendspin/daemon/ - Daemon mode (daemon.py) - sendspin/ - Shared modules (audio, discovery, etc.) Changes: - Create tui/ and daemon/ directories with __init__.py - Move mode-specific files to respective packages - Update all imports to use new package paths - Update CLAUDE.md with new architecture diagram Benefits: - Clearer separation of concerns - Prepares for independent evolution of TUI and daemon modes - Better organization as codebase grows --- CLAUDE.md | 79 +++++++++++++++++---------------- sendspin/cli.py | 4 +- sendspin/daemon/__init__.py | 0 sendspin/{ => daemon}/daemon.py | 4 +- sendspin/tui/__init__.py | 0 sendspin/{ => tui}/app.py | 4 +- sendspin/{ => tui}/keyboard.py | 6 +-- sendspin/{ => tui}/ui.py | 0 8 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 sendspin/daemon/__init__.py rename sendspin/{ => daemon}/daemon.py (99%) create mode 100644 sendspin/tui/__init__.py rename sendspin/{ => tui}/app.py (99%) rename sendspin/{ => tui}/keyboard.py (98%) rename sendspin/{ => tui}/ui.py (100%) diff --git a/CLAUDE.md b/CLAUDE.md index c174395..5b372f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,34 +12,31 @@ Sendspin CLI is a synchronized audio player client for the [Sendspin Protocol](h ┌───────────────┼───────────────┐ │ │ ▼ ▼ - ┌─────────────────────┐ ┌─────────────────────┐ - │ app.py (TUI) │ │ daemon.py (no UI) │ - │ │ │ │ - │ - Interactive mode │ │ - Headless mode │ - │ - Keyboard control │ │ - Signal handlers │ - │ - Rich UI display │ │ - Print events │ - └─────────────────────┘ └─────────────────────┘ + ┌──────────┐ ┌──────────┐ + │ tui/ │ │ daemon/ │ + └──────────┘ └──────────┘ │ │ ┌───────────┼───────────┐ │ │ │ │ │ ▼ ▼ ▼ ▼ -┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│keyboard.py │ │ ui.py │ │audio.py │ │audio.py │ -│ │ │ │ │ │ │ │ -│ - Keys │ │ - TUI │ │ - Sync │ │ - Sync │ -│ - Commands │ │ - Panel │ │ - Output│ │ - Output│ -└────────────┘ └─────────┘ └─────────┘ └─────────┘ +┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ +│ keyboard │ │ ui │ │ app │ │ daemon │ +│ │ │ │ │ │ │ │ +│ - Keys │ │ - TUI │ │ - State │ │ - Headless │ +│ - Commands │ │ - Panel │ │ - Tasks │ │ - Signals │ +└────────────┘ └─────────┘ └─────────┘ └─────────────┘ │ │ │ │ └───────────┼───────────┴───────────────────┘ - ▼ - ┌─────────────────────┐ - │ aiosendspin │ - │ (external library) │ - │ │ - │ - WebSocket client │ - │ - Protocol messages │ - │ - Time sync filter │ - └─────────────────────┘ + │ + ┌───────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌───────────────┐ ┌──────────────┐ +│ audio.py │ │ discovery.py │ │ aiosendspin │ +│ │ │ │ │ (external) │ +│ - Playback │ │ - mDNS │ │ │ +│ - Time sync │ │ - Servers │ │ - WebSocket │ +└─────────────┘ └───────────────┘ └──────────────┘ ``` ## File Responsibilities @@ -54,8 +51,11 @@ Main entry point and orchestrator. Responsibilities: - **Device enumeration**: Lists audio devices and servers via `--list-audio-devices` and `--list-servers` - **Backward compatibility**: Handles deprecated `--headless` flag with warning -### `sendspin/app.py` -Interactive TUI mode (default). Responsibilities: +### `sendspin/tui/` (TUI Mode Package) +Interactive terminal UI mode with keyboard control. + +#### `sendspin/tui/app.py` +TUI mode application logic. Responsibilities: - **Service discovery**: mDNS discovery of Sendspin servers - **Connection management**: `ConnectionManager` handles reconnection with exponential backoff - **State management**: `AppState` dataclass mirrors server state (playback, metadata, volume) @@ -63,16 +63,7 @@ Interactive TUI mode (default). Responsibilities: - **Event callbacks**: Routes server messages to appropriate handlers (metadata, group updates, commands) - **UI coordination**: Manages `SendspinUI` and keyboard input loop -### `sendspin/daemon.py` -Daemon mode for headless operation (via `sendspin daemon` or deprecated `--headless`). Responsibilities: -- **Headless operation**: Runs without UI or keyboard input -- **Service discovery**: Same mDNS discovery as TUI mode -- **Connection management**: Uses same `ConnectionManager` from `app.py` -- **Audio playback**: Same synchronized audio playback -- **Event logging**: Prints events to stdout instead of UI -- **Signal handling**: SIGINT/SIGTERM for graceful shutdown - -### `sendspin/keyboard.py` +#### `sendspin/tui/keyboard.py` Keyboard input handling for interactive control. Responsibilities: - **Key capture**: Uses `readchar` library for single-keypress detection - **Command parsing**: `CommandHandler` class parses and executes commands @@ -81,7 +72,7 @@ Keyboard input handling for interactive control. Responsibilities: - **Delay adjustment**: Real-time static delay adjustment via `delay` command - **Keyboard shortcuts**: Arrow keys (prev/next/volume), space (toggle), m (mute), g (switch group), q (quit) -### `sendspin/ui.py` +#### `sendspin/tui/ui.py` Rich-based terminal UI for visual feedback. Responsibilities: - **Now Playing panel**: Track title, artist, album with playback shortcuts - **Volume panel**: Group and player volume with mute indicators @@ -90,6 +81,18 @@ Rich-based terminal UI for visual feedback. Responsibilities: - **Shortcut highlighting**: Visual feedback when shortcuts are pressed (0.15s highlight) - **State management**: `UIState` dataclass for all display state +### `sendspin/daemon/` (Daemon Mode Package) +Headless operation mode for background services. + +#### `sendspin/daemon/daemon.py` +Daemon mode for headless operation (via `sendspin daemon` or deprecated `--headless`). Responsibilities: +- **Headless operation**: Runs without UI or keyboard input +- **Service discovery**: Same mDNS discovery as TUI mode +- **Connection management**: Reuses `ConnectionManager` from `tui/app.py` +- **Audio playback**: Same synchronized audio playback +- **Event logging**: Prints events to stdout instead of UI +- **Signal handling**: SIGINT/SIGTERM for graceful shutdown + ### `sendspin/audio.py` Time-synchronized audio playback engine. Responsibilities: - **Audio output**: Uses `sounddevice` (PortAudio) for low-latency playback @@ -129,12 +132,12 @@ uv run mypy sendspin # Type check ``` ### Adding a new keyboard shortcut -1. Add key handler in `keyboard.py` `keyboard_loop()` function +1. Add key handler in `tui/keyboard.py` `keyboard_loop()` function 2. Add UI highlight call: `ui.highlight_shortcut("shortcut_name")` 3. Execute command via `handler.execute("command")` -4. Update shortcut display in `ui.py` `_build_now_playing_panel()` or relevant panel +4. Update shortcut display in `tui/ui.py` `_build_now_playing_panel()` or relevant panel ### Adding a new media command 1. Check if `MediaCommand` enum in aiosendspin supports it -2. Add command alias in `CommandHandler.execute()` in `keyboard.py` +2. Add command alias in `CommandHandler.execute()` in `tui/keyboard.py` 3. Command is sent via `client.send_group_command(MediaCommand.XXX)` diff --git a/sendspin/cli.py b/sendspin/cli.py index aa423c0..d62860e 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -202,7 +202,7 @@ def _run_daemon_mode(args: argparse.Namespace) -> int: """Run the client in daemon mode (no UI).""" try: from sendspin.audio import query_devices - from sendspin.daemon import DaemonConfig, SendspinDaemon + from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon except OSError as e: if "PortAudio library not found" in str(e): print(PORTAUDIO_NOT_FOUND_MESSAGE) @@ -305,7 +305,7 @@ def main() -> int: try: from sendspin.audio import query_devices - from sendspin.app import AppConfig, SendspinApp + from sendspin.tui.app import AppConfig, SendspinApp except OSError as e: if "PortAudio library not found" in str(e): print(PORTAUDIO_NOT_FOUND_MESSAGE) diff --git a/sendspin/daemon/__init__.py b/sendspin/daemon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sendspin/daemon.py b/sendspin/daemon/daemon.py similarity index 99% rename from sendspin/daemon.py rename to sendspin/daemon/daemon.py index 9184f49..abba4e7 100644 --- a/sendspin/daemon.py +++ b/sendspin/daemon/daemon.py @@ -24,7 +24,7 @@ Roles, ) -from sendspin.app import ( +from sendspin.tui.app import ( AppState, ConnectionManager, connection_loop, @@ -185,7 +185,7 @@ def signal_handler() -> None: def _setup_listeners(self, listeners: ClientListenerManager) -> None: """Set up client event listeners.""" - from sendspin.app import ( + from sendspin.tui.app import ( _handle_group_update, _handle_metadata_update, _handle_server_command, diff --git a/sendspin/tui/__init__.py b/sendspin/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sendspin/app.py b/sendspin/tui/app.py similarity index 99% rename from sendspin/app.py rename to sendspin/tui/app.py index b96f5ca..b772db3 100644 --- a/sendspin/app.py +++ b/sendspin/tui/app.py @@ -46,8 +46,8 @@ from sendspin.audio_connector import AudioStreamHandler from sendspin.client_listeners import ClientListenerManager from sendspin.discovery import ServiceDiscovery -from sendspin.keyboard import keyboard_loop -from sendspin.ui import SendspinUI +from sendspin.tui.keyboard import keyboard_loop +from sendspin.tui.ui import SendspinUI from sendspin.utils import create_task logger = logging.getLogger(__name__) diff --git a/sendspin/keyboard.py b/sendspin/tui/keyboard.py similarity index 98% rename from sendspin/keyboard.py rename to sendspin/tui/keyboard.py index df5fd35..116bb02 100644 --- a/sendspin/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -11,14 +11,14 @@ import readchar from aiosendspin.models.types import MediaCommand, PlaybackStateType, PlayerStateType -from sendspin.ui import DiscoveredServerInfo +from sendspin.tui.ui import DiscoveredServerInfo if TYPE_CHECKING: from aiosendspin.client import SendspinClient - from sendspin.app import AppState from sendspin.audio_connector import AudioStreamHandler - from sendspin.ui import SendspinUI + from sendspin.tui.app import AppState + from sendspin.tui.ui import SendspinUI logger = logging.getLogger(__name__) diff --git a/sendspin/ui.py b/sendspin/tui/ui.py similarity index 100% rename from sendspin/ui.py rename to sendspin/tui/ui.py From a466896bdedd0281778669a3f36d64282819aa19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 00:38:30 +0000 Subject: [PATCH 05/11] Simplify daemon to be completely independent from TUI Daemon mode now: - Only has PLAYER role (no CONTROLLER, no METADATA) - Uses logging instead of print_event - No state tracking (AppState, metadata, group updates) - No TUI imports - completely independent - Simplified connection logic - just connect and play audio - 165 lines vs previous 216 lines Shared utilities: - Move get_device_info() to utils.py for reuse - Both tui and daemon import from shared utils Benefits: - Daemon is truly minimal - just plays audio - Clear separation: TUI tracks state, daemon just plays - Easier to maintain and understand - No unnecessary complexity in headless mode --- sendspin/daemon/daemon.py | 114 +++++++++++--------------------------- sendspin/tui/app.py | 52 +---------------- sendspin/utils.py | 51 +++++++++++++++++ 3 files changed, 84 insertions(+), 133 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index abba4e7..9fbf0d5 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -1,4 +1,4 @@ -"""Daemon mode for running Sendspin client without UI.""" +"""Daemon mode for running a Sendspin client without UI.""" from __future__ import annotations @@ -8,10 +8,6 @@ import signal import socket from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - pass from aiosendspin.client import SendspinClient from aiosendspin.models.player import ( @@ -19,22 +15,13 @@ PlayerCommand, SupportedAudioFormat, ) -from aiosendspin.models.types import ( - AudioCodec, - Roles, -) +from aiosendspin.models.types import AudioCodec, Roles -from sendspin.tui.app import ( - AppState, - ConnectionManager, - connection_loop, - get_device_info, -) from sendspin.audio import AudioDevice from sendspin.audio_connector import AudioStreamHandler from sendspin.client_listeners import ClientListenerManager from sendspin.discovery import ServiceDiscovery -from sendspin.utils import create_task +from sendspin.utils import create_task, get_device_info logger = logging.getLogger(__name__) @@ -51,20 +38,15 @@ class DaemonConfig: class SendspinDaemon: - """Sendspin daemon - headless mode without UI.""" + """Sendspin daemon - headless audio player mode.""" def __init__(self, config: DaemonConfig) -> None: """Initialize the daemon.""" self._config = config - self._state = AppState() self._client: SendspinClient | None = None self._audio_handler: AudioStreamHandler | None = None self._discovery: ServiceDiscovery | None = None - def _print_event(self, message: str) -> None: - """Print an event message.""" - print(message, flush=True) # noqa: T201 - async def run(self) -> int: """Run the daemon.""" config = self._config @@ -83,12 +65,13 @@ async def run(self) -> int: if client_name is None: client_name = hostname - self._print_event(f"Using client ID: {client_id}") + logger.info("Starting Sendspin daemon: %s", client_id) + # Create client with PLAYER role only - daemon just plays audio self._client = SendspinClient( client_id=client_id, client_name=client_name, - roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA], + roles=[Roles.PLAYER], # Only PLAYER role - no metadata, no controller device_info=get_device_info(), player_support=ClientHelloPlayerSupport( supported_formats=[ @@ -114,33 +97,29 @@ async def run(self) -> int: url = config.url if url is None: logger.info("Waiting for mDNS discovery of Sendspin server...") - self._print_event("Searching for Sendspin server...") try: url = await self._discovery.wait_for_first_server() logger.info("Discovered Sendspin server at %s", url) - self._print_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, ) - self._print_event(f"Using audio device: {config.audio_device.name}") listeners = ClientListenerManager() self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._audio_handler.attach_client(self._client, listeners) - self._setup_listeners(listeners) + # No listeners needed - daemon just plays audio, doesn't track state listeners.attach(self._client) + loop = asyncio.get_running_loop() # Wait forever task for daemon mode - just wait for cancellation @@ -148,68 +127,39 @@ async def wait_forever() -> None: await asyncio.Event().wait() daemon_task = create_task(wait_forever()) - connection_manager = ConnectionManager(self._discovery, daemon_task) def signal_handler() -> None: logger.debug("Received interrupt signal, shutting down...") daemon_task.cancel() - # Signal handlers aren't supported on this platform (e.g., Windows) + # Register signal handlers with contextlib.suppress(NotImplementedError): loop.add_signal_handler(signal.SIGINT, signal_handler) loop.add_signal_handler(signal.SIGTERM, signal_handler) - # Run connection loop with auto-reconnect - await connection_loop( - self._client, - self._discovery, - self._audio_handler, - url, - daemon_task, - self._print_event, - connection_manager, - ui=None, # No UI in daemon mode - ) - except asyncio.CancelledError: - logger.debug("Connection loop cancelled") + # Simple connection loop - just connect and wait + try: + logger.info("Connecting to %s", url) + await self._client.connect(url) + logger.info("Connected successfully") + + # Wait for shutdown signal + await daemon_task + except asyncio.CancelledError: + logger.debug("Daemon shutdown requested") + except Exception: + logger.exception("Error during daemon operation") + 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") + 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() + # Stop discovery await self._discovery.stop() return 0 - - def _setup_listeners(self, listeners: ClientListenerManager) -> None: - """Set up client event listeners.""" - from sendspin.tui.app import ( - _handle_group_update, - _handle_metadata_update, - _handle_server_command, - _handle_server_state, - ) - - assert self._client is not None - client = self._client - loop = asyncio.get_running_loop() - - # Reuse the same listener handlers from app.py, but pass ui=None - listeners.add_metadata_listener( - lambda payload: _handle_metadata_update( - self._state, None, self._print_event, payload - ) - ) - listeners.add_group_update_listener( - lambda payload: _handle_group_update(self._state, None, self._print_event, payload) - ) - listeners.add_controller_state_listener( - lambda payload: _handle_server_state(self._state, None, self._print_event, payload) - ) - listeners.add_server_command_listener( - lambda payload: _handle_server_command( - self._state, client, None, self._print_event, payload, loop - ) - ) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index b772db3..f81fb2f 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -5,15 +5,12 @@ import asyncio import contextlib import logging -import platform import signal import socket import sys from collections.abc import Callable from dataclasses import dataclass, field from functools import partial -from importlib.metadata import version -from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -22,7 +19,6 @@ from aiohttp import ClientError from aiosendspin.client import SendspinClient from aiosendspin.models.core import ( - DeviceInfo, GroupUpdateServerPayload, ServerCommandPayload, ServerStatePayload, @@ -48,7 +44,7 @@ from sendspin.discovery import ServiceDiscovery from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import SendspinUI -from sendspin.utils import create_task +from sendspin.utils import create_task, get_device_info logger = logging.getLogger(__name__) @@ -123,52 +119,6 @@ def describe(self) -> str: return "\n".join(lines) -def get_device_info() -> DeviceInfo: - """Get device information for the client hello message.""" - # Get OS/platform information - system = platform.system() - product_name = f"{system}" - - # Try to get more specific product info - if system == "Linux": - # Try reading /etc/os-release for distribution info - try: - os_release = Path("/etc/os-release") - if os_release.exists(): - with os_release.open() as f: - for line in f: - if line.startswith("PRETTY_NAME="): - product_name = line.split("=", 1)[1].strip().strip('"') - break - except (OSError, IndexError): - pass - elif system == "Darwin": - mac_version = platform.mac_ver()[0] - product_name = f"macOS {mac_version}" if mac_version else "macOS" - elif system == "Windows": - try: - win_ver = platform.win32_ver() - # Check build number to distinguish Windows 11 (build 22000+) from Windows 10 - if win_ver[0] == "10" and win_ver[1] and int(win_ver[1].split(".")[2]) >= 22000: - product_name = "Windows 11" - else: - product_name = f"Windows {win_ver[0]}" - except (ValueError, IndexError, AttributeError): - product_name = f"Windows {platform.release()}" - - # Get software version - try: - software_version = f"aiosendspin {version('aiosendspin')}" - except Exception: # noqa: BLE001 - software_version = "aiosendspin (unknown version)" - - return DeviceInfo( - product_name=product_name, - manufacturer=None, # Could add manufacturer detection if needed - software_version=software_version, - ) - - class ConnectionManager: """Manages connection state and reconnection logic with exponential backoff.""" diff --git a/sendspin/utils.py b/sendspin/utils.py index e38b131..78d0322 100644 --- a/sendspin/utils.py +++ b/sendspin/utils.py @@ -3,10 +3,15 @@ from __future__ import annotations import asyncio +import platform import sys from collections.abc import Coroutine +from importlib.metadata import version +from pathlib import Path from typing import TypeVar +from aiosendspin.models.core import DeviceInfo + _T = TypeVar("_T") # Check if eager_start is supported (Python 3.12+) @@ -47,3 +52,49 @@ def create_task( return asyncio.Task(coro, loop=loop, name=name, eager_start=True) return loop.create_task(coro, name=name) + + +def get_device_info() -> DeviceInfo: + """Get device information for the client hello message.""" + # Get OS/platform information + system = platform.system() + product_name = f"{system}" + + # Try to get more specific product info + if system == "Linux": + # Try reading /etc/os-release for distribution info + try: + os_release = Path("/etc/os-release") + if os_release.exists(): + with os_release.open() as f: + for line in f: + if line.startswith("PRETTY_NAME="): + product_name = line.split("=", 1)[1].strip().strip('"') + break + except (OSError, IndexError): + pass + elif system == "Darwin": + mac_version = platform.mac_ver()[0] + product_name = f"macOS {mac_version}" if mac_version else "macOS" + elif system == "Windows": + try: + win_ver = platform.win32_ver() + # Check build number to distinguish Windows 11 (build 22000+) from Windows 10 + if win_ver[0] == "10" and win_ver[1] and int(win_ver[1].split(".")[2]) >= 22000: + product_name = "Windows 11" + else: + product_name = f"Windows {win_ver[0]}" + except (ValueError, IndexError, AttributeError): + product_name = f"Windows {platform.release()}" + + # Get software version + try: + software_version = f"aiosendspin {version('aiosendspin')}" + except Exception: # noqa: BLE001 + software_version = "aiosendspin (unknown version)" + + return DeviceInfo( + product_name=product_name, + manufacturer=None, # Could add manufacturer detection if needed + software_version=software_version, + ) From 7d8690ae9e8a36f192107c9b2101b60b4d27974f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:16:22 +0100 Subject: [PATCH 06/11] More cleanup --- sendspin/daemon/daemon.py | 8 +- sendspin/tui/app.py | 173 +++++++++++++++++--------------------- sendspin/tui/keyboard.py | 44 ++++------ 3 files changed, 95 insertions(+), 130 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 9fbf0d5..4b51a58 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -10,12 +10,8 @@ from dataclasses import dataclass from aiosendspin.client import SendspinClient -from aiosendspin.models.player import ( - ClientHelloPlayerSupport, - PlayerCommand, - SupportedAudioFormat, -) -from aiosendspin.models.types import AudioCodec, Roles +from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat +from aiosendspin.models.types import AudioCodec, PlayerCommand, Roles from sendspin.audio import AudioDevice from sendspin.audio_connector import AudioStreamHandler diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index f81fb2f..0fc47c5 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -8,7 +8,6 @@ import signal import socket import sys -from collections.abc import Callable from dataclasses import dataclass, field from functools import partial from typing import TYPE_CHECKING @@ -191,21 +190,21 @@ def increase_backoff(self) -> None: """Increase the backoff duration for the next retry.""" self._error_backoff = min(self._error_backoff * 2, self._max_backoff) - async def handle_error_backoff(self, print_event: Callable[[str], None]) -> bool: + async def handle_error_backoff(self, ui: SendspinUI) -> bool: """Sleep for error backoff with keyboard interrupt support. Returns True if interrupted by keyboard, False if completed normally. """ - print_event(f"Connection error, retrying in {self._error_backoff:.0f}s...") + 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, print_event: Callable[[str], None]) -> str | None: + async def wait_for_server_reappear(self, ui: SendspinUI) -> str | 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...") - print_event("Waiting for server...") + ui.add_event("Waiting for server...") # Poll for discovery or keyboard exit while not self._keyboard_task.done(): @@ -223,9 +222,8 @@ async def connection_loop( # noqa: PLR0915 audio_handler: AudioStreamHandler, initial_url: str, keyboard_task: asyncio.Task[None], - print_event: Callable[[str], None], connection_manager: ConnectionManager, - ui: SendspinUI | None = None, + ui: SendspinUI, ) -> None: """ Run the connection loop with automatic reconnection on disconnect. @@ -240,9 +238,8 @@ async def connection_loop( # noqa: PLR0915 audio_handler: Audio stream handler. initial_url: Initial server URL. keyboard_task: Keyboard input task to monitor. - print_event: Function to print events. connection_manager: Connection manager for reconnection logic. - ui: Optional UI instance. + ui: UI instance. """ manager = connection_manager url = initial_url @@ -252,9 +249,8 @@ async def connection_loop( # noqa: PLR0915 try: await client.connect(url) logger.info("Connected to %s", url) - print_event(f"Connected to {url}") - if ui is not None: - ui.set_connected(url) + ui.add_event(f"Connected to {url}") + ui.set_connected(url) manager.reset_backoff() manager.set_last_attempted_url(url) @@ -272,9 +268,8 @@ async def connection_loop( # noqa: PLR0915 # Connection dropped logger.info("Connection lost") - print_event("Connection lost") - if ui is not None: - ui.set_disconnected("Connection lost") + ui.add_event("Connection lost") + ui.set_disconnected("Connection lost") # Clean up audio state await audio_handler.cleanup() @@ -284,9 +279,8 @@ async def connection_loop( # noqa: PLR0915 if pending_url: url = pending_url manager.reset_backoff() - print_event(f"Switching to {url}...") - if ui is not None: - ui.set_disconnected(f"Switching to {url}...") + ui.add_event(f"Switching to {url}...") + ui.set_disconnected(f"Switching to {url}...") continue # Update URL from discovery @@ -294,18 +288,16 @@ async def connection_loop( # noqa: PLR0915 # Wait for server to reappear if it's gone if not new_url: - if ui is not None: - ui.set_disconnected("Waiting for server...") - new_url = await manager.wait_for_server_reappear(print_event) + 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 - print_event(f"Reconnecting to {url}...") - if ui is not None: - ui.set_disconnected(f"Reconnecting to {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 @@ -315,7 +307,7 @@ async def connection_loop( # noqa: PLR0915 manager.get_error_backoff(), ) - if await manager.handle_error_backoff(print_event): + if await manager.handle_error_backoff(ui): break # Check if URL changed while sleeping @@ -326,7 +318,7 @@ async def connection_loop( # noqa: PLR0915 except Exception: # Unexpected errors - log with full traceback logger.exception("Unexpected error during connection") - print_event("Unexpected error occurred") + ui.add_event("Unexpected error occurred") await asyncio.sleep(manager.get_error_backoff()) manager.increase_backoff() @@ -348,28 +340,40 @@ class SendspinApp: def __init__(self, config: AppConfig) -> None: """Initialize the application.""" self._config = config - self._ui: SendspinUI | None = None + self._ui: SendspinUI self._state = AppState() self._client: SendspinClient | None = None self._audio_handler: AudioStreamHandler | None = None self._discovery: ServiceDiscovery | None = None - def _print_event(self, message: str) -> None: - """Print an event message.""" - if self._ui is not None: - self._ui.add_event(message) - else: - print(message, flush=True) # noqa: T201 - 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 sys.stdin.isatty() and logging.getLogger().level != logging.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 @@ -384,7 +388,7 @@ async def run(self) -> int: # noqa: PLR0915 if client_name is None: client_name = hostname - self._print_event(f"Using client ID: {client_id}") + self._ui.add_event(f"Using client ID: {client_id}") self._client = SendspinClient( client_id=client_id, @@ -415,11 +419,11 @@ async def run(self) -> int: # noqa: PLR0915 url = config.url if url is None: logger.info("Waiting for mDNS discovery of Sendspin server...") - self._print_event("Searching for 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._print_event(f"Found server at {url}") + self._ui.add_event(f"Found server at {url}") except asyncio.CancelledError: # When KeyboardInterrupt occurs during discovery return 1 @@ -433,18 +437,15 @@ async def run(self) -> int: # noqa: PLR0915 config.audio_device.index, config.audio_device.name, ) - self._print_event(f"Using audio device: {config.audio_device.name}") + self._ui.add_event(f"Using audio device: {config.audio_device.name}") listeners = ClientListenerManager() self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._audio_handler.attach_client(self._client, listeners) - # Create UI for interactive mode - if sys.stdin.isatty(): - self._ui = SendspinUI() - self._ui.start() - self._ui.set_delay(self._client.static_delay_ms) + # Set delay now that client is created + self._ui.set_delay(self._client.static_delay_ms) self._setup_listeners(listeners) listeners.attach(self._client) @@ -474,7 +475,6 @@ async def on_server_selected(new_url: str) -> None: self._state, self._audio_handler, self._ui, - self._print_event, get_servers, on_server_selected, ) @@ -498,7 +498,6 @@ def signal_handler() -> None: self._audio_handler, url, keyboard_task, - self._print_event, connection_manager, self._ui, ) @@ -509,14 +508,11 @@ def signal_handler() -> None: with contextlib.suppress(NotImplementedError): loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGTERM) - await self._audio_handler.cleanup() + if self._audio_handler is not None: + await self._audio_handler.cleanup() await self._client.disconnect() await self._discovery.stop() - # Stop UI - if self._ui is not None: - self._ui.stop() - # Show hint if delay was changed during session current_delay = self._client.static_delay_ms if current_delay != config.static_delay_ms: @@ -534,45 +530,38 @@ def _setup_listeners(self, listeners: ClientListenerManager) -> None: loop = asyncio.get_running_loop() listeners.add_metadata_listener( - lambda payload: _handle_metadata_update( - self._state, self._ui, self._print_event, payload - ) + lambda payload: _handle_metadata_update(self._state, self._ui, payload) ) listeners.add_group_update_listener( - lambda payload: _handle_group_update(self._state, self._ui, self._print_event, payload) + lambda payload: _handle_group_update(self._state, self._ui, payload) ) listeners.add_controller_state_listener( - lambda payload: _handle_server_state(self._state, self._ui, self._print_event, payload) + 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, self._print_event, payload, loop - ) + lambda payload: _handle_server_command(self._state, client, self._ui, payload, loop) ) def _handle_metadata_update( state: AppState, - ui: SendspinUI | None, - print_event: Callable[[str], None], + ui: SendspinUI, payload: ServerStatePayload, ) -> None: """Handle server/state messages with metadata.""" if payload.metadata is not None and state.update_metadata(payload.metadata): - if ui is not None: - ui.set_metadata( - title=state.title, - artist=state.artist, - album=state.album, - ) - ui.set_progress(state.track_progress, state.track_duration) - print_event(state.describe()) + 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( state: AppState, - ui: SendspinUI | None, - print_event: Callable[[str], None], + ui: SendspinUI, payload: GroupUpdateServerPayload, ) -> None: """Handle group update messages.""" @@ -585,26 +574,22 @@ def _handle_group_update( state.album = None state.track_progress = None state.track_duration = None - if ui is not None: - ui.set_metadata(title=None, artist=None, album=None) - ui.clear_progress() - print_event(f"Group ID: {payload.group_id}") + 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: - print_event(f"Group name: {payload.group_name}") - if ui is not None: - ui.set_group_name(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 - if ui is not None: - ui.set_playback_state(payload.playback_state) - print_event(f"Playback state: {payload.playback_state.value}") + 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 | None, - print_event: Callable[[str], None], + ui: SendspinUI, payload: ServerStatePayload, ) -> None: """Handle server/state messages with controller state.""" @@ -617,20 +602,19 @@ def _handle_server_state( if volume_changed: state.volume = controller.volume - print_event(f"Volume: {controller.volume}%") + ui.add_event(f"Volume: {controller.volume}%") if mute_changed: state.muted = controller.muted - print_event("Muted" if controller.muted else "Unmuted") + ui.add_event("Muted" if controller.muted else "Unmuted") - if ui is not None and (volume_changed or mute_changed): + if volume_changed or mute_changed: ui.set_volume(state.volume, muted=state.muted) def _handle_server_command( state: AppState, client: SendspinClient, - ui: SendspinUI | None, - print_event: Callable[[str], None], + ui: SendspinUI, payload: ServerCommandPayload, loop: asyncio.AbstractEventLoop, ) -> None: @@ -642,14 +626,12 @@ def _handle_server_command( if player_cmd.command == PlayerCommand.VOLUME and player_cmd.volume is not None: state.player_volume = player_cmd.volume - if ui is not None: - ui.set_player_volume(state.player_volume, muted=state.player_muted) - print_event(f"Server set 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 - if ui is not None: - ui.set_player_volume(state.player_volume, muted=state.player_muted) - print_event("Server muted player" if player_cmd.mute else "Server unmuted player") + 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( @@ -657,5 +639,6 @@ def _handle_server_command( 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 116bb02..0f3f47c 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -31,24 +31,22 @@ def __init__( client: SendspinClient, state: AppState, audio_handler: AudioStreamHandler, - ui: SendspinUI | None = None, - print_event: Callable[[str], None] | None = None, - get_servers: Callable[[], list[tuple[str, str, str, int]]] | None = None, - on_server_selected: Callable[[str], Awaitable[None]] | None = None, + 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._print_event = print_event or (lambda _: None) 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.""" if command not in self._state.supported_commands: - self._print_event(f"Server does not support {command.value}") + self._ui.add_event(f"Server does not support {command.value}") return await self._client.send_group_command(command) @@ -68,14 +66,13 @@ async def change_player_volume(self, delta: int) -> None: self._audio_handler.audio_player.set_volume( self._state.player_volume, muted=self._state.player_muted ) - if self._ui is not None: - self._ui.set_player_volume(self._state.player_volume, muted=self._state.player_muted) + self._ui.set_player_volume(self._state.player_volume, muted=self._state.player_muted) await self._client.send_player_state( state=PlayerStateType.SYNCHRONIZED, volume=self._state.player_volume, muted=self._state.player_muted, ) - self._print_event(f"Player volume: {target}%") + self._ui.add_event(f"Player volume: {target}%") async def toggle_player_mute(self) -> None: """Toggle player (local) mute state.""" @@ -85,25 +82,21 @@ async def toggle_player_mute(self) -> None: self._audio_handler.audio_player.set_volume( self._state.player_volume, muted=self._state.player_muted ) - if self._ui is not None: - self._ui.set_player_volume(self._state.player_volume, muted=self._state.player_muted) + self._ui.set_player_volume(self._state.player_volume, muted=self._state.player_muted) await self._client.send_player_state( state=PlayerStateType.SYNCHRONIZED, volume=self._state.player_volume, muted=self._state.player_muted, ) - self._print_event("Player muted" if self._state.player_muted else "Player unmuted") + self._ui.add_event("Player muted" if self._state.player_muted else "Player unmuted") async def adjust_delay(self, delta: float) -> None: """Adjust static delay by delta milliseconds.""" self._client.set_static_delay_ms(self._client.static_delay_ms + delta) - if self._ui is not None: - self._ui.set_delay(self._client.static_delay_ms) + self._ui.set_delay(self._client.static_delay_ms) def open_server_selector(self) -> None: """Open the server selector panel.""" - if self._ui is None or self._get_servers is None: - return server_tuples = self._get_servers() servers = [ DiscoveredServerInfo(name=name, url=url, host=host, port=port) @@ -113,13 +106,10 @@ def open_server_selector(self) -> None: def close_server_selector(self) -> None: """Close the server selector panel.""" - if self._ui is not None: - self._ui.hide_server_selector() + self._ui.hide_server_selector() async def select_server(self) -> None: """Select the highlighted server and connect to it.""" - if self._ui is None or self._on_server_selected is None: - return server = self._ui.get_selected_server() if server is not None: self._ui.hide_server_selector() @@ -132,10 +122,9 @@ async def keyboard_loop( client: SendspinClient, state: AppState, audio_handler: AudioStreamHandler, - ui: SendspinUI | None = None, - print_event: Callable[[str], None] | None = None, - get_servers: Callable[[], list[tuple[str, str, str, int]]] | None = None, - on_server_selected: Callable[[str], Awaitable[None]] | None = None, + ui: SendspinUI, + get_servers: Callable[[], list[tuple[str, str, str, int]]], + on_server_selected: Callable[[str], Awaitable[None]], ) -> None: """Run the keyboard input loop. @@ -143,14 +132,11 @@ async def keyboard_loop( client: Sendspin client instance. state: Application state. audio_handler: Audio stream handler. - ui: Optional UI instance. - print_event: Function to print events. + ui: UI instance. 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, print_event, get_servers, on_server_selected - ) + handler = CommandHandler(client, state, audio_handler, ui, get_servers, on_server_selected) # Key dispatch table: key -> (highlight_name | None, async action) # For keys that need case-insensitive matching, use lowercase From 9f85008993b993ef4d89f76d7692073c648043ce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:17:32 +0100 Subject: [PATCH 07/11] Remove extra comment --- sendspin/daemon/daemon.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 4b51a58..eab1cd5 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -63,11 +63,10 @@ async def run(self) -> int: logger.info("Starting Sendspin daemon: %s", client_id) - # Create client with PLAYER role only - daemon just plays audio self._client = SendspinClient( client_id=client_id, client_name=client_name, - roles=[Roles.PLAYER], # Only PLAYER role - no metadata, no controller + roles=[Roles.PLAYER], device_info=get_device_info(), player_support=ClientHelloPlayerSupport( supported_formats=[ From bee2f82fb10ce07a8bf756f8d9e14e40a9d2840f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:19:01 +0100 Subject: [PATCH 08/11] Remove extra comment --- sendspin/daemon/daemon.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index eab1cd5..e74f1a9 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -83,7 +83,6 @@ async def run(self) -> int: static_delay_ms=config.static_delay_ms, ) - # Start service discovery self._discovery = ServiceDiscovery() await self._discovery.start() @@ -112,7 +111,6 @@ async def run(self) -> int: self._audio_handler = AudioStreamHandler(audio_device=config.audio_device) self._audio_handler.attach_client(self._client, listeners) - # No listeners needed - daemon just plays audio, doesn't track state listeners.attach(self._client) loop = asyncio.get_running_loop() From 2c37739bda1ba315d5fc34a3c3ad61d82e17e9dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:23:52 +0100 Subject: [PATCH 09/11] Add reconnect for daemon back --- sendspin/daemon/daemon.py | 120 ++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index e74f1a9..96c7d77 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -8,7 +8,9 @@ import signal import socket from dataclasses import dataclass +from functools import partial +from aiohttp import ClientError from aiosendspin.client import SendspinClient from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat from aiosendspin.models.types import AudioCodec, PlayerCommand, Roles @@ -42,6 +44,7 @@ def __init__(self, config: DaemonConfig) -> None: 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.""" @@ -115,33 +118,20 @@ async def run(self) -> int: loop = asyncio.get_running_loop() - # Wait forever task for daemon mode - just wait for cancellation - async def wait_forever() -> None: - await asyncio.Event().wait() - - daemon_task = create_task(wait_forever()) + self._shutdown_event = asyncio.Event() def signal_handler() -> None: logger.debug("Received interrupt signal, shutting down...") - daemon_task.cancel() + 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) - # Simple connection loop - just connect and wait try: - logger.info("Connecting to %s", url) - await self._client.connect(url) - logger.info("Connected successfully") - - # Wait for shutdown signal - await daemon_task - except asyncio.CancelledError: - logger.debug("Daemon shutdown requested") - except Exception: - logger.exception("Error during daemon operation") + await self._connection_loop(url) finally: # Remove signal handlers with contextlib.suppress(NotImplementedError): @@ -156,3 +146,99 @@ def signal_handler() -> None: await self._discovery.stop() return 0 + + async def _connection_loop(self, initial_url: str) -> 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 + + while not self._shutdown_event.is_set(): + 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()) + + done, pending = await asyncio.wait( + {shutdown_task, disconnect_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + self._client.set_disconnect_listener(None) + + if shutdown_task in done: + break + + # Connection dropped + logger.info("Disconnected from server") + await self._audio_handler.cleanup() + + # 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) + + if self._shutdown_event.is_set(): + break + + logger.info("Reconnecting to %s", url) + + except (TimeoutError, OSError, ClientError) as e: + logger.warning( + "Connection error (%s), retrying in %.0fs", + type(e).__name__, + error_backoff, + ) + + # Interruptible sleep + try: + await asyncio.wait_for(self._shutdown_event.wait(), timeout=error_backoff) + break # Shutdown requested + except TimeoutError: + pass # Sleep completed, continue loop + + # Check if URL changed while sleeping + new_url = self._discovery.current_url() + if new_url and new_url != url: + logger.info("Server URL changed to %s", new_url) + url = new_url + error_backoff = 1.0 + else: + error_backoff = min(error_backoff * 2, max_backoff) + + 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) From 6f8acc49be0470b1b3a0521b6549c0af382911b0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:26:25 +0100 Subject: [PATCH 10/11] only use discovery when no url given --- sendspin/daemon/daemon.py | 57 ++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 96c7d77..273cd88 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -131,7 +131,7 @@ def signal_handler() -> None: loop.add_signal_handler(signal.SIGTERM, signal_handler) try: - await self._connection_loop(url) + await self._connection_loop(url, use_discovery=config.url is None) finally: # Remove signal handlers with contextlib.suppress(NotImplementedError): @@ -147,7 +147,7 @@ def signal_handler() -> None: return 0 - async def _connection_loop(self, initial_url: str) -> None: + 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 @@ -191,23 +191,24 @@ async def _connection_loop(self, initial_url: str) -> None: logger.info("Disconnected from server") await self._audio_handler.cleanup() - # 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) - - if self._shutdown_event.is_set(): - break + 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) + + if self._shutdown_event.is_set(): + break logger.info("Reconnecting to %s", url) @@ -225,14 +226,16 @@ async def _connection_loop(self, initial_url: str) -> None: except TimeoutError: pass # Sleep completed, continue loop - # Check if URL changed while sleeping - new_url = self._discovery.current_url() - if new_url and new_url != url: - logger.info("Server URL changed to %s", new_url) - url = new_url - error_backoff = 1.0 - else: - error_backoff = min(error_backoff * 2, max_backoff) + # Check if URL changed while sleeping (only when using discovery) + if use_discovery: + new_url = self._discovery.current_url() + if new_url and new_url != url: + logger.info("Server URL changed to %s", new_url) + url = new_url + error_backoff = 1.0 + continue + + error_backoff = min(error_backoff * 2, max_backoff) except Exception: logger.exception("Unexpected error during connection") From a4bb31003e55293c88a5333e49a7c68109818403 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jan 2026 16:38:10 +0100 Subject: [PATCH 11/11] Standardize audio resolving --- sendspin/cli.py | 142 +++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 73 deletions(-) diff --git a/sendspin/cli.py b/sendspin/cli.py index d62860e..1467eef 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -6,6 +6,10 @@ import asyncio import sys from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sendspin.audio import AudioDevice PORTAUDIO_NOT_FOUND_MESSAGE = """Error: PortAudio library not found. @@ -82,7 +86,9 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) # Daemon subcommand - daemon_parser = subparsers.add_parser("daemon", help="Run Sendspin client in daemon mode (no UI)") + daemon_parser = subparsers.add_parser( + "daemon", help="Run Sendspin client in daemon mode (no UI)" + ) daemon_parser.add_argument( "--url", default=None, @@ -198,50 +204,58 @@ async def list_servers() -> None: sys.exit(1) -def _run_daemon_mode(args: argparse.Namespace) -> int: - """Run the client in daemon mode (no UI).""" - try: - from sendspin.audio import query_devices - from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon - except OSError as e: - if "PortAudio library not found" in str(e): - print(PORTAUDIO_NOT_FOUND_MESSAGE) - return 1 - raise +class CLIError(Exception): + """CLI error with exit code.""" + + def __init__(self, message: str, exit_code: int = 1) -> None: + super().__init__(message) + self.exit_code = exit_code + + +def _resolve_audio_device(device_arg: str | None) -> AudioDevice: + """Resolve audio device from CLI argument. + + Args: + device_arg: Device specifier (index number, name prefix, or None for default). + + Returns: + The resolved AudioDevice. + + Raises: + CLIError: If the device cannot be found. + """ + from sendspin.audio import query_devices - # Resolve audio device if specified - audio_device = None devices = query_devices() - if args.audio_device is None: - audio_device = next((d for d in devices if d.is_default), None) - elif args.audio_device.isnumeric(): - device_id = int(args.audio_device) - for dev in devices: - if dev.index == device_id: - audio_device = dev - break + + if device_arg is None: + device = next((d for d in devices if d.is_default), None) + elif device_arg.isnumeric(): + device_id = int(device_arg) + device = next((d for d in devices if d.index == device_id), None) else: - # Otherwise, find first output device whose name starts with the prefix - for dev in devices: - if dev.name.startswith(args.audio_device): - audio_device = dev - break - - if audio_device is None: - dev_type = "Default" if args.audio_device is None else "Specified" - print(f"Error: {dev_type} audio device not found.") - return 1 - - # Create config from CLI arguments + # Find first output device whose name starts with the prefix + device = next((d for d in devices if d.name.startswith(device_arg)), None) + + if device is None: + dev_type = "Default" if device_arg is None else "Specified" + raise CLIError(f"{dev_type} audio device not found.") + + return device + + +def _run_daemon_mode(args: argparse.Namespace) -> int: + """Run the client in daemon mode (no UI).""" + from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon + daemon_config = DaemonConfig( + audio_device=_resolve_audio_device(args.audio_device), url=args.url, client_id=args.id, client_name=args.name, static_delay_ms=args.static_delay_ms, - audio_device=audio_device, ) - # Run the daemon daemon = SendspinDaemon(daemon_config) return asyncio.run(daemon.run()) @@ -284,10 +298,6 @@ def main() -> int: traceback.print_exc() return 1 - # Handle daemon subcommand - if args.command == "daemon": - return _run_daemon_mode(args) - # Handle --list-audio-devices before starting async runtime if args.list_audio_devices: list_audio_devices() @@ -297,54 +307,40 @@ def main() -> int: asyncio.run(list_servers()) return 0 - # 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) - try: - from sendspin.audio import query_devices - from sendspin.tui.app import AppConfig, SendspinApp + return _run_client_mode(args) + except CLIError as e: + print(f"Error: {e}") + return e.exit_code except OSError as e: if "PortAudio library not found" in str(e): print(PORTAUDIO_NOT_FOUND_MESSAGE) return 1 raise - # Resolve audio device if specified - audio_device = None - devices = query_devices() - if args.audio_device is None: - audio_device = next((d for d in devices if d.is_default), None) - elif args.audio_device.isnumeric(): - device_id = int(args.audio_device) - for dev in devices: - if dev.index == device_id: - audio_device = dev - break - else: - # Otherwise, find first output device whose name starts with the prefix - for dev in devices: - if dev.name.startswith(args.audio_device): - audio_device = dev - break - - if audio_device is None: - dev_type = "Default" if args.audio_device is None else "Specified" - print(f"Error: {dev_type} audio device not found.") - return 1 - - # Create config from CLI arguments + +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) + + # 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) + + from sendspin.tui.app import AppConfig, SendspinApp + app_config = AppConfig( + audio_device=_resolve_audio_device(args.audio_device), url=args.url, client_id=args.id, client_name=args.name, static_delay_ms=args.static_delay_ms, - audio_device=audio_device, ) - # Run the application app = SendspinApp(app_config) return asyncio.run(app.run())