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