diff --git a/CLAUDE.md b/CLAUDE.md index 7e25ba1..5b372f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,34 +7,36 @@ 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) + │ + ┌───────────────┼───────────────┐ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ tui/ │ │ daemon/ │ + └──────────┘ └──────────┘ + │ │ + ┌───────────┼───────────┐ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ +│ keyboard │ │ ui │ │ app │ │ daemon │ +│ │ │ │ │ │ │ │ +│ - Keys │ │ - TUI │ │ - State │ │ - Headless │ +│ - Commands │ │ - Panel │ │ - Tasks │ │ - Signals │ +└────────────┘ └─────────┘ └─────────┘ └─────────────┘ + │ │ │ │ + └───────────┼───────────┴───────────────────┘ + │ + ┌───────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌───────────────┐ ┌──────────────┐ +│ audio.py │ │ discovery.py │ │ aiosendspin │ +│ │ │ │ │ (external) │ +│ - Playback │ │ - mDNS │ │ │ +│ - Time sync │ │ - Servers │ │ - WebSocket │ +└─────────────┘ └───────────────┘ └──────────────┘ ``` ## File Responsibilities @@ -44,14 +46,24 @@ 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/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**: `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/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 @@ -60,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 @@ -69,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 @@ -94,8 +118,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 @@ -106,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 bacd593..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. @@ -81,6 +85,47 @@ 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 +176,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 +204,62 @@ async def list_servers() -> None: sys.exit(1) +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 + + devices = query_devices() + + 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: + # 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, + ) + + daemon = SendspinDaemon(daemon_config) + return asyncio.run(daemon.run()) + + def main() -> int: """Run the CLI client.""" import logging @@ -207,48 +308,39 @@ def main() -> int: return 0 try: - from sendspin.audio import query_devices - from sendspin.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, - headless=args.headless, ) - # Run the application app = SendspinApp(app_config) return asyncio.run(app.run()) diff --git a/sendspin/daemon/__init__.py b/sendspin/daemon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py new file mode 100644 index 0000000..273cd88 --- /dev/null +++ b/sendspin/daemon/daemon.py @@ -0,0 +1,247 @@ +"""Daemon mode for running a Sendspin client without UI.""" + +from __future__ import annotations + +import asyncio +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 +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 +from sendspin.client_listeners import ClientListenerManager +from sendspin.discovery import ServiceDiscovery +from sendspin.utils import create_task, get_device_info + +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 audio player mode.""" + + 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, + roles=[Roles.PLAYER], + 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, + ) + + 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...") + 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, + ) + + 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") + + finally: + # Stop discovery + await self._discovery.stop() + + 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 + + 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() + + 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) + + 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 (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") + try: + await asyncio.wait_for(self._shutdown_event.wait(), timeout=error_backoff) + break + except TimeoutError: + pass + error_backoff = min(error_backoff * 2, max_backoff) 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 63% rename from sendspin/app.py rename to sendspin/tui/app.py index a9d132e..0fc47c5 100644 --- a/sendspin/app.py +++ b/sendspin/tui/app.py @@ -5,15 +5,11 @@ 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 +18,6 @@ from aiohttp import ClientError from aiosendspin.client import SendspinClient from aiosendspin.models.core import ( - DeviceInfo, GroupUpdateServerPayload, ServerCommandPayload, ServerStatePayload, @@ -46,9 +41,9 @@ 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.utils import create_task +from sendspin.tui.keyboard import keyboard_loop +from sendspin.tui.ui import SendspinUI +from sendspin.utils import create_task, get_device_info logger = logging.getLogger(__name__) @@ -123,52 +118,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.""" @@ -241,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(): @@ -273,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. @@ -290,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 @@ -302,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) @@ -322,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() @@ -334,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 @@ -344,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 @@ -365,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 @@ -376,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() @@ -390,7 +332,6 @@ class AppConfig: client_id: str | None = None client_name: str | None = None static_delay_ms: float = 0.0 - headless: bool = False class SendspinApp: @@ -399,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 @@ -435,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, @@ -466,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 @@ -484,112 +437,90 @@ 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 (unless headless) - if sys.stdin.isatty() and not config.headless: - 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) 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() - - 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, - ) - ) - - connection_manager = ConnectionManager(self._discovery, 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) - 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() - finally: - # 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." - ) + # 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, + get_servers, + on_server_selected, + ) + ) + connection_manager = ConnectionManager(self._discovery, 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) + 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, + keyboard_task, + 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) + if self._audio_handler is not None: + await self._audio_handler.cleanup() + await self._client.disconnect() await self._discovery.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: @@ -599,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.""" @@ -650,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.""" @@ -682,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: @@ -707,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( @@ -722,5 +639,6 @@ def _handle_server_command( state=PlayerStateType.SYNCHRONIZED, volume=state.player_volume, muted=state.player_muted, - ) + ), + loop=loop, ) diff --git a/sendspin/keyboard.py b/sendspin/tui/keyboard.py similarity index 81% rename from sendspin/keyboard.py rename to sendspin/tui/keyboard.py index df5fd35..0f3f47c 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__) @@ -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 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 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, + )