Skip to main content

sonos_cli/cli/
run.rs

1use std::io::IsTerminal;
2
3use sonos_sdk::{SeekTarget, SonosSystem};
4
5use super::{
6    format_duration_human, format_time_ms, parse_duration, playback_icon, playback_label,
7    require_speaker_only, resolve_group, resolve_speaker, validate_seek_time, Commands,
8    GlobalFlags, OnOff, QueueAction,
9};
10use crate::config::Config;
11use crate::diagnostics;
12use crate::errors::CliError;
13
14pub fn run_command(
15    cmd: Commands,
16    system: &SonosSystem,
17    config: &Config,
18    global: &GlobalFlags,
19) -> Result<String, CliError> {
20    let spk = || resolve_speaker(system, config, global);
21
22    match cmd {
23        Commands::Speakers => cmd_speakers(system),
24        Commands::Groups => cmd_groups(system),
25        Commands::Status => cmd_status(system, config, global),
26        Commands::Join => cmd_join(system, global),
27        Commands::Leave => cmd_leave(system, global),
28        Commands::Bass { level } => cmd_bass(system, global, level),
29        Commands::Treble { level } => cmd_treble(system, global, level),
30        Commands::Loudness { state } => cmd_loudness(system, global, state),
31        Commands::Sleep { duration } => cmd_sleep(system, config, global, &duration),
32        Commands::Queue { action } => cmd_queue(system, config, global, action),
33
34        Commands::Play => {
35            let s = spk()?;
36            s.play()?;
37            Ok(format!("Playing ({})", s.name))
38        }
39        Commands::Pause => {
40            let s = spk()?;
41            s.pause()?;
42            Ok(format!("Paused ({})", s.name))
43        }
44        Commands::Stop => {
45            let s = spk()?;
46            s.stop()?;
47            Ok(format!("Stopped ({})", s.name))
48        }
49        Commands::Next => {
50            let s = spk()?;
51            s.next()?;
52            Ok(format!("Next track ({})", s.name))
53        }
54        Commands::Previous => {
55            let s = spk()?;
56            s.previous()?;
57            Ok(format!("Previous track ({})", s.name))
58        }
59        Commands::Seek { position } => {
60            validate_seek_time(&position)?;
61            let s = spk()?;
62            s.seek(SeekTarget::Time(position.clone()))?;
63            Ok(format!("Seeked to {} ({})", position, s.name))
64        }
65        Commands::Mode { mode } => {
66            let s = spk()?;
67            s.set_play_mode(mode.to_sdk())?;
68            Ok(format!("Mode set to {:?} ({})", mode, s.name))
69        }
70        Commands::Volume { level } => cmd_volume(system, config, global, level),
71        Commands::Mute => cmd_mute(system, config, global, true),
72        Commands::Unmute => cmd_mute(system, config, global, false),
73    }
74}
75
76// -- Command handlers ---------------------------------------------------------
77
78fn cmd_speakers(system: &SonosSystem) -> Result<String, CliError> {
79    let speakers = system.speakers();
80    if speakers.is_empty() {
81        eprintln!("{}", diagnostics::discovery_hint());
82        return Ok("No speakers found".to_string());
83    }
84    let lines: Vec<String> = speakers
85        .iter()
86        .map(|s| {
87            let state = s.playback_state.fetch().ok();
88            let vol = s.volume.fetch().ok();
89            let group_name = s
90                .group()
91                .and_then(|g| g.coordinator().map(|c| c.name))
92                .unwrap_or_default();
93
94            let state_str = state
95                .as_ref()
96                .map(|st| format!("{} {}", playback_icon(st), playback_label(st)))
97                .unwrap_or_default();
98            let vol_str = vol.map(|v| format!("vol:{}", v.0)).unwrap_or_default();
99
100            let mut parts = vec![s.name.clone()];
101            if !state_str.is_empty() {
102                parts.push(state_str);
103            }
104            if !vol_str.is_empty() {
105                parts.push(vol_str);
106            }
107            if !group_name.is_empty() {
108                parts.push(format!("({group_name})"));
109            }
110            parts.join("   ")
111        })
112        .collect();
113    Ok(lines.join("\n"))
114}
115
116fn cmd_groups(system: &SonosSystem) -> Result<String, CliError> {
117    let groups = system.groups();
118    if groups.is_empty() {
119        eprintln!("{}", diagnostics::discovery_hint());
120        return Ok("No groups found".to_string());
121    }
122    let lines: Vec<String> = groups
123        .iter()
124        .map(|g| {
125            let coord = g.coordinator();
126            let coord_name = coord
127                .as_ref()
128                .map(|c| c.name.clone())
129                .unwrap_or_else(|| "unknown".to_string());
130
131            let state = coord.as_ref().and_then(|c| c.playback_state.fetch().ok());
132            let track = coord.as_ref().and_then(|c| c.current_track.fetch().ok());
133            let vol = g.volume.fetch().ok();
134
135            let state_str = state
136                .as_ref()
137                .map(|st| format!("{} {}", playback_icon(st), playback_label(st)))
138                .unwrap_or_default();
139            let track_str = track.as_ref().map(|t| t.display()).unwrap_or_default();
140            let vol_str = vol.map(|v| format!("vol:{}", v.0)).unwrap_or_default();
141
142            let mut parts = vec![coord_name];
143            if !state_str.is_empty() {
144                parts.push(state_str);
145            }
146            if !track_str.is_empty() {
147                parts.push(track_str);
148            }
149            if !vol_str.is_empty() {
150                parts.push(vol_str);
151            }
152            parts.join("   ")
153        })
154        .collect();
155    Ok(lines.join("\n"))
156}
157
158fn cmd_volume(
159    system: &SonosSystem,
160    config: &Config,
161    global: &GlobalFlags,
162    level: u8,
163) -> Result<String, CliError> {
164    // Explicit --speaker (without --group) → Speaker.set_volume(u8)
165    if global.speaker.is_some() && global.group.is_none() {
166        let s = resolve_speaker(system, config, global)?;
167        s.set_volume(level)?;
168        return Ok(format!("Volume set to {} ({})", level, s.name));
169    }
170    // Otherwise → Group.set_volume(u16) via GroupRenderingControl
171    let g = resolve_group(system, config, global)?;
172    let name = g
173        .coordinator()
174        .map(|c| c.name)
175        .unwrap_or_else(|| "unknown".to_string());
176    g.set_volume(level as u16)?;
177    Ok(format!("Volume set to {level} ({name})"))
178}
179
180fn cmd_mute(
181    system: &SonosSystem,
182    config: &Config,
183    global: &GlobalFlags,
184    muted: bool,
185) -> Result<String, CliError> {
186    let label = if muted { "Muted" } else { "Unmuted" };
187    // Explicit --speaker (without --group) → Speaker.set_mute(bool)
188    if global.speaker.is_some() && global.group.is_none() {
189        let s = resolve_speaker(system, config, global)?;
190        s.set_mute(muted)?;
191        return Ok(format!("{} ({})", label, s.name));
192    }
193    // Otherwise → Group.set_mute(bool) via GroupRenderingControl
194    let g = resolve_group(system, config, global)?;
195    let name = g
196        .coordinator()
197        .map(|c| c.name)
198        .unwrap_or_else(|| "unknown".to_string());
199    g.set_mute(muted)?;
200    Ok(format!("{label} ({name})"))
201}
202
203fn cmd_status(
204    system: &SonosSystem,
205    config: &Config,
206    global: &GlobalFlags,
207) -> Result<String, CliError> {
208    let spk = resolve_speaker(system, config, global)?;
209    let state = spk.playback_state.fetch().ok();
210    let track = spk.current_track.fetch().ok();
211    let pos = spk.position.fetch().ok();
212    let vol = spk.volume.fetch().ok();
213
214    let state_str = state
215        .as_ref()
216        .map(|st| format!("{} {}", playback_icon(st), playback_label(st)))
217        .unwrap_or_else(|| "unknown".to_string());
218    let track_str = track.as_ref().map(|t| t.display()).unwrap_or_default();
219    let pos_str = pos
220        .as_ref()
221        .map(|p| {
222            format!(
223                "{}/{}",
224                format_time_ms(p.position_ms),
225                format_time_ms(p.duration_ms)
226            )
227        })
228        .unwrap_or_default();
229    let vol_str = vol.map(|v| format!("vol:{}", v.0)).unwrap_or_default();
230
231    let mut parts = vec![spk.name.clone(), state_str];
232    if !track_str.is_empty() {
233        parts.push(track_str);
234    }
235    if !pos_str.is_empty() {
236        parts.push(pos_str);
237    }
238    if !vol_str.is_empty() {
239        parts.push(vol_str);
240    }
241    Ok(parts.join("  "))
242}
243
244fn cmd_join(system: &SonosSystem, global: &GlobalFlags) -> Result<String, CliError> {
245    let speaker_name = global
246        .speaker
247        .as_deref()
248        .ok_or_else(|| CliError::Validation("--speaker is required for join".into()))?;
249    let group_name = global
250        .group
251        .as_deref()
252        .ok_or_else(|| CliError::Validation("--group is required for join".into()))?;
253    let spk = system
254        .speaker(speaker_name)
255        .ok_or_else(|| CliError::SpeakerNotFound(speaker_name.into()))?;
256    let grp = system
257        .group(group_name)
258        .ok_or_else(|| CliError::GroupNotFound(group_name.into()))?;
259    grp.add_speaker(&spk)?;
260    Ok(format!("{speaker_name} joined {group_name}"))
261}
262
263fn cmd_leave(system: &SonosSystem, global: &GlobalFlags) -> Result<String, CliError> {
264    let speaker_name = global
265        .speaker
266        .as_deref()
267        .ok_or_else(|| CliError::Validation("--speaker is required for leave".into()))?;
268    let spk = system
269        .speaker(speaker_name)
270        .ok_or_else(|| CliError::SpeakerNotFound(speaker_name.into()))?;
271    let group_name = spk
272        .group()
273        .and_then(|g| g.coordinator().map(|c| c.name))
274        .unwrap_or_else(|| "its group".into());
275    spk.leave_group()?;
276    Ok(format!("{speaker_name} left {group_name}"))
277}
278
279fn cmd_bass(system: &SonosSystem, global: &GlobalFlags, level: i8) -> Result<String, CliError> {
280    let spk = require_speaker_only(system, global, "bass")?;
281    spk.set_bass(level)?;
282    Ok(format!("Bass set to {} ({})", level, spk.name))
283}
284
285fn cmd_treble(system: &SonosSystem, global: &GlobalFlags, level: i8) -> Result<String, CliError> {
286    let spk = require_speaker_only(system, global, "treble")?;
287    spk.set_treble(level)?;
288    Ok(format!("Treble set to {} ({})", level, spk.name))
289}
290
291fn cmd_loudness(
292    system: &SonosSystem,
293    global: &GlobalFlags,
294    state: OnOff,
295) -> Result<String, CliError> {
296    let spk = require_speaker_only(system, global, "loudness")?;
297    let enabled = matches!(state, OnOff::On);
298    spk.set_loudness(enabled)?;
299    if enabled {
300        Ok(format!("Loudness enabled ({})", spk.name))
301    } else {
302        Ok(format!("Loudness disabled ({})", spk.name))
303    }
304}
305
306fn cmd_sleep(
307    system: &SonosSystem,
308    config: &Config,
309    global: &GlobalFlags,
310    duration: &str,
311) -> Result<String, CliError> {
312    let spk = resolve_speaker(system, config, global)?;
313    if duration == "cancel" {
314        spk.cancel_sleep_timer()?;
315        Ok(format!("Sleep timer cancelled ({})", spk.name))
316    } else {
317        let hh_mm_ss = parse_duration(duration)?;
318        let human = format_duration_human(duration);
319        spk.configure_sleep_timer(&hh_mm_ss)?;
320        Ok(format!("Sleep timer set for {} ({})", human, spk.name))
321    }
322}
323
324fn cmd_queue(
325    system: &SonosSystem,
326    config: &Config,
327    global: &GlobalFlags,
328    action: Option<QueueAction>,
329) -> Result<String, CliError> {
330    let spk = resolve_speaker(system, config, global)?;
331    match action {
332        None => {
333            let info = spk.get_media_info()?;
334            if info.nr_tracks == 0 {
335                return Ok(format!("queue is empty ({})", spk.name));
336            }
337            Ok(format!("{} — {} tracks", spk.name, info.nr_tracks))
338        }
339        Some(QueueAction::Add { uri }) => {
340            spk.add_uri_to_queue(&uri, "", 0, false)?;
341            Ok(format!("Added to queue ({})", spk.name))
342        }
343        Some(QueueAction::Clear) => {
344            if std::io::stdin().is_terminal() && !global.no_input {
345                eprint!("Clear queue for {}? [y/N] ", spk.name);
346                let mut input = String::new();
347                std::io::stdin()
348                    .read_line(&mut input)
349                    .map_err(|e| CliError::Validation(e.to_string()))?;
350                if !input.trim().eq_ignore_ascii_case("y") {
351                    return Ok("Cancelled".into());
352                }
353            }
354            spk.remove_all_tracks_from_queue()?;
355            Ok(format!("Queue cleared ({})", spk.name))
356        }
357    }
358}