aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:35:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:35:56 +0100
commit5dfb5b4ce521a44eb4e9173d20c58e665c78e979 (patch)
tree514508f9bef90444b867a68abfeb2ad365c44d14 /src
parentaudio: open the output at the track's rate instead of pw-alsa's 44100 (diff)
downloadrsplayer-5dfb5b4ce521a44eb4e9173d20c58e665c78e979.tar.gz
rsplayer-5dfb5b4ce521a44eb4e9173d20c58e665c78e979.zip
ctl: interactive shell with history, editing and completion
`rsp ctl` opens a prompt in the spirit of bluetoothctl. Lines are parsed with the same clap definitions as the one-shot CLI, so every command and flag works exactly as it does from a terminal, `help <command>` included. Tab completes subcommands, and track/album/artist paths for `play` through `db-complete` (run out of process, keeping its 100 ms timeout away from the input loop). History lives in the state directory. Piped input falls back to a plain line loop, so `rsp ctl < commands` works without a terminal.
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs5
-rw-r--r--src/ctl.rs359
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs1
4 files changed, 366 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 79c31a8..8b39519 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -104,6 +104,8 @@ pub enum Commands {
104 }, 104 },
105 /// display lyrics 105 /// display lyrics
106 Lyrics, 106 Lyrics,
107 /// Interactive control shell (like bluetoothctl)
108 Ctl,
107} 109}
108 110
109#[derive(Subcommand)] 111#[derive(Subcommand)]
@@ -350,6 +352,9 @@ impl Cli {
350 Commands::Lyrics => { 352 Commands::Lyrics => {
351 let _ = crate::daemon::send_command("lyrics".to_string()); 353 let _ = crate::daemon::send_command("lyrics".to_string());
352 } 354 }
355 Commands::Ctl => {
356 crate::ctl::run()?;
357 }
353 } 358 }
354 Ok(()) 359 Ok(())
355 } 360 }
diff --git a/src/ctl.rs b/src/ctl.rs
new file mode 100644
index 0000000..5cb2f3b
--- /dev/null
+++ b/src/ctl.rs
@@ -0,0 +1,359 @@
1/*
2 * This file is a part of RSPlayer
3 * Licensed under the GPL-3.0 License
4 * Copyright (C) 2026 Salmonik @ salmonik@tuta.io
5 * https://codeberg.org/salmonik/RSPlayer
6*/
7
8//! Interactive control shell (`rsp ctl`), in the spirit of `bluetoothctl`.
9//!
10//! Every line is parsed with the same clap definitions as the one-shot CLI, so
11//! the shell understands exactly the same commands (`play --now …`,
12//! `volume +5`, `settings set …`) instead of keeping a second, drifting copy.
13//! History is kept in the state directory, and Tab completes commands plus
14//! track/album paths for `play`.
15
16use crate::cli::Cli;
17use clap::{CommandFactory, Parser};
18use rustyline::completion::{Completer, Pair};
19use rustyline::error::ReadlineError;
20use rustyline::highlight::Highlighter;
21use rustyline::hint::Hinter;
22use rustyline::history::DefaultHistory;
23use rustyline::validate::Validator;
24use rustyline::{Context, Editor, Helper};
25use std::io::IsTerminal;
26use std::process::{Command, Stdio};
27
28const PROMPT: &str = "rsp> ";
29
30/// Commands the shell handles itself.
31const LOCAL_COMMANDS: [&str; 5] = ["help", "quit", "exit", "clear", "?"];
32
33struct CtlHelper {
34 commands: Vec<String>,
35}
36
37impl Default for CtlHelper {
38 fn default() -> Self {
39 let mut commands: Vec<String> = Cli::command()
40 .get_subcommands()
41 .map(|command| command.get_name().to_string())
42 .chain(LOCAL_COMMANDS.iter().map(|c| c.to_string()))
43 .collect();
44 commands.sort();
45 commands.dedup();
46 Self { commands }
47 }
48}
49
50impl Helper for CtlHelper {}
51impl Highlighter for CtlHelper {}
52impl Hinter for CtlHelper {
53 type Hint = String;
54}
55impl Validator for CtlHelper {}
56
57impl Completer for CtlHelper {
58 type Candidate = Pair;
59
60 fn complete(
61 &self,
62 line: &str,
63 pos: usize,
64 _ctx: &Context<'_>,
65 ) -> rustyline::Result<(usize, Vec<Pair>)> {
66 let before = &line[..pos];
67 let Some(start) = before.rfind(char::is_whitespace).map(|i| i + 1) else {
68 let prefix = before.to_string();
69 let matches = self
70 .commands
71 .iter()
72 .filter(|command| command.starts_with(&prefix))
73 .map(|command| Pair {
74 display: command.clone(),
75 replacement: command.clone(),
76 })
77 .collect();
78 return Ok((0, matches));
79 };
80
81 let word = &before[start..];
82 let command = before[..start].trim();
83 let matches = match command {
84 "play" | "play-album" => complete_music(word),
85 _ => Vec::new(),
86 };
87 Ok((start, matches))
88 }
89}
90
91/// Candidate tracks/albums for the current prefix, from `rsp db-complete`.
92///
93/// Completion runs out of process on purpose: `db-complete` already applies the
94/// 100 ms database timeout and the filesystem fallback, so a slow or missing
95/// index cannot hang the shell's input loop.
96fn complete_music(prefix: &str) -> Vec<Pair> {
97 let Ok(exe) = std::env::current_exe() else {
98 return Vec::new();
99 };
100 let Ok(output) = Command::new(exe)
101 .arg("db-complete")
102 .arg(prefix)
103 .stderr(Stdio::null())
104 .output()
105 else {
106 return Vec::new();
107 };
108
109 String::from_utf8_lossy(&output.stdout)
110 .lines()
111 .map(str::trim)
112 .filter(|candidate| !candidate.is_empty())
113 // The CLI escapes spaces for shells; the shell's own reader needs the
114 // real text back and re-quotes it below.
115 .map(unescape)
116 .map(|candidate| Pair {
117 display: candidate.clone(),
118 replacement: quote(&candidate),
119 })
120 .collect()
121}
122
123/// Undo the shell escaping `rsp db-complete` applies.
124fn unescape(candidate: &str) -> String {
125 let mut out = String::with_capacity(candidate.len());
126 let mut chars = candidate.chars();
127 while let Some(c) = chars.next() {
128 if c == '\\' {
129 if let Some(next) = chars.next() {
130 out.push(next);
131 }
132 } else {
133 out.push(c);
134 }
135 }
136 out
137}
138
139/// Quote a candidate that contains spaces, so it stays one argument.
140fn quote(candidate: &str) -> String {
141 if candidate.contains(char::is_whitespace) {
142 format!("'{}'", candidate.replace('\'', r"'\''"))
143 } else {
144 candidate.to_string()
145 }
146}
147
148/// Split a command line into arguments, honouring quotes and backslash
149/// escapes so that track names with spaces survive.
150fn split_args(line: &str) -> Vec<String> {
151 let mut args = Vec::new();
152 let mut current = String::new();
153 let mut quote: Option<char> = None;
154 let mut in_word = false;
155 let mut chars = line.chars();
156
157 while let Some(c) = chars.next() {
158 match quote {
159 Some(q) => {
160 if c == q {
161 quote = None;
162 } else if c == '\\' && q == '"' {
163 current.extend(chars.next());
164 } else {
165 current.push(c);
166 }
167 }
168 None => match c {
169 '\'' | '"' => {
170 quote = Some(c);
171 in_word = true;
172 }
173 '\\' => {
174 current.extend(chars.next());
175 in_word = true;
176 }
177 c if c.is_whitespace() => {
178 if in_word {
179 args.push(std::mem::take(&mut current));
180 in_word = false;
181 }
182 }
183 c => {
184 current.push(c);
185 in_word = true;
186 }
187 },
188 }
189 }
190 if in_word {
191 args.push(current);
192 }
193 args
194}
195
196enum Step {
197 Continue,
198 Quit,
199}
200
201fn dispatch(line: &str) -> Result<Step, Box<dyn std::error::Error>> {
202 let mut args = split_args(line);
203 let Some(command) = args.first().cloned() else {
204 return Ok(Step::Continue);
205 };
206
207 match command.as_str() {
208 "quit" | "exit" | "q" => return Ok(Step::Quit),
209 "clear" => {
210 print!("\x1b[2J\x1b[H");
211 return Ok(Step::Continue);
212 }
213 "help" | "?" => {
214 let mut cli = Cli::command();
215 match args.get(1).and_then(|name| cli.find_subcommand_mut(name)) {
216 Some(subcommand) => {
217 subcommand.print_help()?;
218 println!();
219 }
220 None => {
221 cli.print_help()?;
222 println!();
223 }
224 }
225 return Ok(Step::Continue);
226 }
227 // These replace or block the process the shell runs in.
228 "start-daemon" | "ctl" => {
229 return Err(format!(
230 "`{}` would take over the shell — run it from a normal terminal",
231 command
232 )
233 .into());
234 }
235 _ => {}
236 }
237
238 args.insert(0, "rsp".to_string());
239 match Cli::try_parse_from(args) {
240 Ok(cli) => cli.run()?,
241 Err(e)
242 if matches!(
243 e.kind(),
244 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
245 ) =>
246 {
247 print!("{}", e);
248 }
249 Err(e) => eprintln!("{}", e.render()),
250 }
251 Ok(Step::Continue)
252}
253
254pub fn run() -> Result<(), Box<dyn std::error::Error>> {
255 if !std::io::stdin().is_terminal() {
256 return run_piped();
257 }
258
259 let history_path = crate::config::runtime_dir().join("ctl_history");
260 let mut editor: Editor<CtlHelper, DefaultHistory> = Editor::default()?;
261 let _ = editor.load_history(&history_path);
262
263 if !crate::config::Config::get_socket_path().exists() {
264 eprintln!("note: no daemon socket — start it with `start-daemon` or the service");
265 }
266 println!("RSPlayer control shell — `help` lists commands, `quit` leaves.");
267 loop {
268 match editor.readline(PROMPT) {
269 Ok(line) => {
270 let line = line.trim().to_string();
271 if line.is_empty() {
272 continue;
273 }
274 let _ = editor.add_history_entry(line.as_str());
275 match dispatch(&line) {
276 Ok(Step::Continue) => {}
277 Ok(Step::Quit) => break,
278 Err(e) => eprintln!("Error: {}", e),
279 }
280 }
281 // Ctrl-C drops the line, Ctrl-D leaves.
282 Err(ReadlineError::Interrupted) => continue,
283 Err(ReadlineError::Eof) => break,
284 Err(e) => {
285 eprintln!("Input error: {}", e);
286 break;
287 }
288 }
289 }
290
291 let _ = editor.save_history(&history_path);
292 Ok(())
293}
294
295/// Same commands without an editor, for pipes and scripts
296/// (`rsp ctl < commands.txt`), where rustyline has no terminal to drive.
297fn run_piped() -> Result<(), Box<dyn std::error::Error>> {
298 use std::io::BufRead;
299
300 for line in std::io::stdin().lock().lines() {
301 match dispatch(line?.trim()) {
302 Ok(Step::Continue) => {}
303 Ok(Step::Quit) => break,
304 Err(e) => eprintln!("Error: {}", e),
305 }
306 }
307 Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use clap::Parser;
314
315 #[test]
316 fn splits_plain_and_quoted_arguments() {
317 assert_eq!(split_args("seek 1:30"), vec!["seek", "1:30"]);
318 assert_eq!(
319 split_args("play 'Some Album/01 Track.mp3'"),
320 vec!["play", "Some Album/01 Track.mp3"]
321 );
322 assert_eq!(
323 split_args(r#"play "A \"quoted\" name""#),
324 vec!["play", r#"A "quoted" name"#]
325 );
326 assert_eq!(split_args(" status "), vec!["status"]);
327 assert!(split_args(" ").is_empty());
328 }
329
330 #[test]
331 fn quoted_arguments_round_trip_through_the_cli() {
332 let args = split_args("seek +15");
333 let mut full = vec!["rsp".to_string()];
334 full.extend(args);
335 let cli = Cli::try_parse_from(full).expect("seek +15 is a valid command");
336 assert!(matches!(cli.command, crate::cli::Commands::Seek { .. }));
337 }
338
339 #[test]
340 fn quotes_single_word_arguments_only_when_needed() {
341 assert_eq!(quote("AFX"), "AFX");
342 assert_eq!(
343 quote("Analogue Bubblebath, Vol 3.1"),
344 "'Analogue Bubblebath, Vol 3.1'"
345 );
346 }
347
348 #[test]
349 fn completes_command_names() {
350 let helper = CtlHelper::default();
351 for expected in ["play", "seek", "settings", "status", "quit"] {
352 assert!(
353 helper.commands.iter().any(|c| c == expected),
354 "completion is missing `{}`",
355 expected
356 );
357 }
358 }
359}
diff --git a/src/lib.rs b/src/lib.rs
index 189c376..fe0a3b6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,6 +15,7 @@ pub fn debug_eprintln(args: core::fmt::Arguments<'_>) {
15pub mod audio; 15pub mod audio;
16pub mod cli; 16pub mod cli;
17pub mod config; 17pub mod config;
18pub mod ctl;
18pub mod daemon; 19pub mod daemon;
19pub mod database; 20pub mod database;
20pub mod mpris; 21pub mod mpris;
diff --git a/src/main.rs b/src/main.rs
index 4bc4498..0138ea2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@
8mod audio; 8mod audio;
9mod cli; 9mod cli;
10mod config; 10mod config;
11mod ctl;
11mod daemon; 12mod daemon;
12mod database; 13mod database;
13mod mpris; 14mod mpris;