1111import traceback
1212from collections .abc import Sequence
1313from importlib .metadata import version
14- from typing import TYPE_CHECKING
14+ from typing import TYPE_CHECKING , Any , Protocol
1515
1616from sendspin .hardware_volume import AVAILABLE as HW_VOLUME_AVAILABLE
1717from sendspin .hardware_volume import UNAVAILABLE_REASON as HW_VOLUME_UNAVAILABLE_REASON
3232 • macOS: brew install portaudio
3333 • Other systems: https://www.portaudio.com/"""
3434
35+ PLAYER_APP_SENTINEL = "player"
36+
37+
38+ class ArgumentTarget (Protocol ):
39+ """Minimal protocol for parser-like objects that accept arguments."""
40+
41+ def add_argument (self , * name_or_flags : str , ** kwargs : Any ) -> argparse .Action :
42+ """Add an argument to the target."""
43+
3544
3645def arg_str_to_bool (v : str ) -> bool :
3746 s = v .lower ()
@@ -72,15 +81,145 @@ def list_audio_devices() -> None:
7281 sys .exit (1 )
7382
7483
75- def parse_args (argv : Sequence [str ] | None = None ) -> argparse .Namespace :
76- """Parse CLI arguments for the Sendspin client."""
77- parser = argparse .ArgumentParser (description = "Sendspin CLI" )
84+ def _add_player_runtime_options (
85+ target : ArgumentTarget , * , suppress_defaults : bool = False
86+ ) -> None :
87+ """Add the interactive player's runtime options."""
88+ default : str | float | None
89+ default = argparse .SUPPRESS if suppress_defaults else None
90+
91+ target .add_argument (
92+ "--url" ,
93+ default = default ,
94+ help = ("WebSocket URL of the Sendspin server. If omitted, discover via mDNS." ),
95+ )
96+ target .add_argument (
97+ "--name" ,
98+ default = default ,
99+ help = "Friendly name for this client (defaults to hostname)" ,
100+ )
101+ target .add_argument (
102+ "--id" ,
103+ default = default ,
104+ help = "Unique identifier for this client (defaults to sendspin-cli-<hostname>)" ,
105+ )
106+ target .add_argument (
107+ "--log-level" ,
108+ default = default ,
109+ choices = ["DEBUG" , "INFO" , "WARNING" , "ERROR" , "CRITICAL" ],
110+ help = "Logging level to use (default: INFO)" ,
111+ )
112+ target .add_argument (
113+ "--static-delay-ms" ,
114+ type = float ,
115+ default = default ,
116+ help = "Extra playback delay in milliseconds applied after clock sync" ,
117+ )
118+ target .add_argument (
119+ "--audio-device" ,
120+ type = str ,
121+ default = default ,
122+ help = (
123+ "Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). "
124+ "Use --list-audio-devices to see available devices."
125+ ),
126+ )
127+ target .add_argument (
128+ "--audio-format" ,
129+ type = str ,
130+ default = default ,
131+ help = (
132+ "Preferred audio format as codec:sample_rate:bit_depth:channels "
133+ "(e.g., flac:48000:24:2). Verified against the audio device on startup."
134+ ),
135+ )
136+ target .add_argument (
137+ "--disable-mpris" ,
138+ action = "store_true" ,
139+ default = argparse .SUPPRESS if suppress_defaults else False ,
140+ help = "Disable MPRIS integration" ,
141+ )
142+ target .add_argument (
143+ "--hardware-volume" ,
144+ default = default ,
145+ type = arg_str_to_bool ,
146+ metavar = "{true,false}" ,
147+ help = "Enable or disable hardware/system volume control (daemon: on, TUI: off)" ,
148+ )
149+ target .add_argument (
150+ "--hook-start" ,
151+ type = str ,
152+ default = default ,
153+ help = "Command to run when audio stream starts (receives SENDSPIN_* env vars)" ,
154+ )
155+ target .add_argument (
156+ "--hook-stop" ,
157+ type = str ,
158+ default = default ,
159+ help = "Command to run when audio stream stops (receives SENDSPIN_* env vars)" ,
160+ )
161+
162+
163+ def _add_player_actions (target : ArgumentTarget , * , suppress_defaults : bool = False ) -> None :
164+ """Add actions that should also work with the player app."""
165+ target .add_argument (
166+ "--list-audio-devices" ,
167+ action = "store_true" ,
168+ default = argparse .SUPPRESS if suppress_defaults else False ,
169+ help = "List available audio output devices and exit" ,
170+ )
171+ target .add_argument (
172+ "--list-servers" ,
173+ action = "store_true" ,
174+ default = argparse .SUPPRESS if suppress_defaults else False ,
175+ help = "Discover and list available Sendspin servers on the network" ,
176+ )
177+ target .add_argument (
178+ "--list-clients" ,
179+ action = "store_true" ,
180+ default = argparse .SUPPRESS if suppress_defaults else False ,
181+ help = "Discover and list available Sendspin clients on the network" ,
182+ )
183+ target .add_argument (
184+ "--headless" ,
185+ action = "store_true" ,
186+ default = argparse .SUPPRESS if suppress_defaults else False ,
187+ help = argparse .SUPPRESS ,
188+ )
189+
190+
191+ def _build_parser () -> argparse .ArgumentParser :
192+ """Build the top-level CLI parser."""
193+ parser = argparse .ArgumentParser (
194+ prog = "sendspin" ,
195+ description = "Sendspin CLI" ,
196+ )
197+
198+ # Keep top-level actions separate from the TUI player's runtime options.
199+ parser ._optionals .title = "Actions"
78200 parser .add_argument (
79201 "--version" ,
80202 action = "version" ,
81203 version = f"%(prog)s { version ('sendspin' )} " ,
82204 )
83- subparsers = parser .add_subparsers (dest = "command" , help = "Available commands" )
205+ subparsers = parser .add_subparsers (
206+ dest = "command" ,
207+ title = "Apps" ,
208+ help = "Available apps (default: player)" ,
209+ )
210+
211+ player_parser = subparsers .add_parser (
212+ PLAYER_APP_SENTINEL ,
213+ description = "Run the interactive player app." ,
214+ help = "Run the interactive player app (default)" ,
215+ )
216+ player_parser .add_argument (
217+ "--version" ,
218+ action = "version" ,
219+ version = f"%(prog)s { version ('sendspin' )} " ,
220+ )
221+ _add_player_runtime_options (player_parser , suppress_defaults = True )
222+ _add_player_actions (player_parser , suppress_defaults = True )
84223
85224 # Serve subcommand
86225 serve_parser = subparsers .add_parser ("serve" , help = "Start a Sendspin server" )
@@ -205,6 +344,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
205344 "--hardware-volume" ,
206345 default = None ,
207346 type = arg_str_to_bool ,
347+ metavar = "{true,false}" ,
208348 help = "Enable or disable hardware/system volume control (daemon: on, TUI: off)" ,
209349 )
210350 daemon_parser .add_argument (
@@ -221,95 +361,15 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
221361 )
222362
223363 # Default behavior (client mode) - existing arguments
224- parser .add_argument (
225- "--url" ,
226- default = None ,
227- help = ("WebSocket URL of the Sendspin server. If omitted, discover via mDNS." ),
228- )
229- parser .add_argument (
230- "--name" ,
231- default = None ,
232- help = "Friendly name for this client (defaults to hostname)" ,
233- )
234- parser .add_argument (
235- "--id" ,
236- default = None ,
237- help = "Unique identifier for this client (defaults to sendspin-cli-<hostname>)" ,
238- )
239- parser .add_argument (
240- "--log-level" ,
241- default = None ,
242- choices = ["DEBUG" , "INFO" , "WARNING" , "ERROR" , "CRITICAL" ],
243- help = "Logging level to use (default: INFO)" ,
244- )
245- parser .add_argument (
246- "--static-delay-ms" ,
247- type = float ,
248- default = None ,
249- help = "Extra playback delay in milliseconds applied after clock sync" ,
250- )
251- parser .add_argument (
252- "--audio-device" ,
253- type = str ,
254- default = None ,
255- help = (
256- "Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). "
257- "Use --list-audio-devices to see available devices."
258- ),
259- )
260- parser .add_argument (
261- "--audio-format" ,
262- type = str ,
263- default = None ,
264- help = (
265- "Preferred audio format as codec:sample_rate:bit_depth:channels "
266- "(e.g., flac:48000:24:2). Verified against the audio device on startup."
267- ),
268- )
269- parser .add_argument (
270- "--list-audio-devices" ,
271- action = "store_true" ,
272- help = "List available audio output devices and exit" ,
273- )
274- parser .add_argument (
275- "--list-servers" ,
276- action = "store_true" ,
277- help = "Discover and list available Sendspin servers on the network" ,
278- )
279- parser .add_argument (
280- "--list-clients" ,
281- action = "store_true" ,
282- help = "Discover and list available Sendspin clients on the network" ,
283- )
284- parser .add_argument (
285- "--disable-mpris" ,
286- action = "store_true" ,
287- help = "Disable MPRIS integration" ,
288- )
289- parser .add_argument (
290- "--hardware-volume" ,
291- default = None ,
292- type = arg_str_to_bool ,
293- help = "Enable or disable hardware/system volume control (daemon: on, TUI: off)" ,
294- )
295- parser .add_argument (
296- "--headless" ,
297- action = "store_true" ,
298- help = "(DEPRECATED: use 'sendspin daemon' instead) Run without the interactive terminal UI" ,
299- )
300- parser .add_argument (
301- "--hook-start" ,
302- type = str ,
303- default = None ,
304- help = "Command to run when audio stream starts (receives SENDSPIN_* env vars)" ,
305- )
306- parser .add_argument (
307- "--hook-stop" ,
308- type = str ,
309- default = None ,
310- help = "Command to run when audio stream stops (receives SENDSPIN_* env vars)" ,
311- )
312- return parser .parse_args (argv )
364+ tui_player_options = parser .add_argument_group ("Player options" )
365+ _add_player_runtime_options (tui_player_options )
366+ _add_player_actions (parser )
367+ return parser
368+
369+
370+ def parse_args (argv : Sequence [str ] | None = None ) -> argparse .Namespace :
371+ """Parse CLI arguments for the Sendspin client."""
372+ return _build_parser ().parse_args (argv )
313373
314374
315375async def list_servers () -> None :
0 commit comments