aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--Cargo.lock82
-rw-r--r--Cargo.toml1
-rw-r--r--README.md1
-rw-r--r--src/cli.rs5
-rw-r--r--src/ctl.rs359
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs1
7 files changed, 450 insertions, 0 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 7a4524a..13f394e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -19,6 +19,7 @@ dependencies = [
19 "rand 0.10.1", 19 "rand 0.10.1",
20 "reqwest", 20 "reqwest",
21 "rusqlite", 21 "rusqlite",
22 "rustyline",
22 "serde", 23 "serde",
23 "serde_json", 24 "serde_json",
24 "symphonia", 25 "symphonia",
@@ -422,6 +423,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
422checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 423checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
423 424
424[[package]] 425[[package]]
426name = "clipboard-win"
427version = "5.4.3"
428source = "registry+https://github.com/rust-lang/crates.io-index"
429checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
430dependencies = [
431 "error-code",
432]
433
434[[package]]
425name = "cmake" 435name = "cmake"
426version = "0.1.58" 436version = "0.1.58"
427source = "registry+https://github.com/rust-lang/crates.io-index" 437source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -667,6 +677,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
667checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" 677checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
668 678
669[[package]] 679[[package]]
680name = "endian-type"
681version = "0.2.0"
682source = "registry+https://github.com/rust-lang/crates.io-index"
683checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2"
684
685[[package]]
670name = "enumflags2" 686name = "enumflags2"
671version = "0.7.12" 687version = "0.7.12"
672source = "registry+https://github.com/rust-lang/crates.io-index" 688source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -704,6 +720,12 @@ dependencies = [
704] 720]
705 721
706[[package]] 722[[package]]
723name = "error-code"
724version = "3.4.0"
725source = "registry+https://github.com/rust-lang/crates.io-index"
726checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
727
728[[package]]
707name = "event-listener" 729name = "event-listener"
708version = "5.4.1" 730version = "5.4.1"
709source = "registry+https://github.com/rust-lang/crates.io-index" 731source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -961,6 +983,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
961checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 983checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
962 984
963[[package]] 985[[package]]
986name = "home"
987version = "0.5.12"
988source = "registry+https://github.com/rust-lang/crates.io-index"
989checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
990dependencies = [
991 "windows-sys 0.61.2",
992]
993
994[[package]]
964name = "http" 995name = "http"
965version = "1.4.0" 996version = "1.4.0"
966source = "registry+https://github.com/rust-lang/crates.io-index" 997source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1459,6 +1490,15 @@ dependencies = [
1459] 1490]
1460 1491
1461[[package]] 1492[[package]]
1493name = "nibble_vec"
1494version = "0.1.0"
1495source = "registry+https://github.com/rust-lang/crates.io-index"
1496checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
1497dependencies = [
1498 "smallvec",
1499]
1500
1501[[package]]
1462name = "nix" 1502name = "nix"
1463version = "0.31.2" 1503version = "0.31.2"
1464source = "registry+https://github.com/rust-lang/crates.io-index" 1504source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1861,6 +1901,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1861checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 1901checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1862 1902
1863[[package]] 1903[[package]]
1904name = "radix_trie"
1905version = "0.3.0"
1906source = "registry+https://github.com/rust-lang/crates.io-index"
1907checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a"
1908dependencies = [
1909 "endian-type",
1910 "nibble_vec",
1911]
1912
1913[[package]]
1864name = "rand" 1914name = "rand"
1865version = "0.9.4" 1915version = "0.9.4"
1866source = "registry+https://github.com/rust-lang/crates.io-index" 1916source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2115,6 +2165,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2115checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2165checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
2116 2166
2117[[package]] 2167[[package]]
2168name = "rustyline"
2169version = "19.0.0"
2170source = "registry+https://github.com/rust-lang/crates.io-index"
2171checksum = "56e1d6bd81c2d5feafd2419138df9183c45a10a492c4e873b8b0acb8eb2b549e"
2172dependencies = [
2173 "bitflags",
2174 "clipboard-win",
2175 "home",
2176 "libc",
2177 "log",
2178 "memchr",
2179 "nix",
2180 "radix_trie",
2181 "unicode-segmentation",
2182 "unicode-width",
2183 "utf8parse",
2184 "windows-sys 0.61.2",
2185]
2186
2187[[package]]
2118name = "ryu" 2188name = "ryu"
2119version = "1.0.23" 2189version = "1.0.23"
2120source = "registry+https://github.com/rust-lang/crates.io-index" 2190source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2843,6 +2913,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2843checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 2913checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2844 2914
2845[[package]] 2915[[package]]
2916name = "unicode-segmentation"
2917version = "1.13.3"
2918source = "registry+https://github.com/rust-lang/crates.io-index"
2919checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
2920
2921[[package]]
2922name = "unicode-width"
2923version = "0.2.3"
2924source = "registry+https://github.com/rust-lang/crates.io-index"
2925checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
2926
2927[[package]]
2846name = "unicode-xid" 2928name = "unicode-xid"
2847version = "0.2.6" 2929version = "0.2.6"
2848source = "registry+https://github.com/rust-lang/crates.io-index" 2930source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 51eebbb..ee4a077 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ notify-rust = "4.17.0"
36md-5 = "0.11.0" 36md-5 = "0.11.0"
37hex = "0.4.3" 37hex = "0.4.3"
38zbus = { version = "5", default-features = false, features = ["blocking-api"] } 38zbus = { version = "5", default-features = false, features = ["blocking-api"] }
39rustyline = "19.0.0"
39 40
40[profile.release] 41[profile.release]
41opt-level = "z" 42opt-level = "z"
diff --git a/README.md b/README.md
index 678d085..f8d4ff5 100644
--- a/README.md
+++ b/README.md
@@ -69,4 +69,5 @@ these are normal commands:
69- `status` Show player status 69- `status` Show player status
70- `scan` Scan music paths to populate database 70- `scan` Scan music paths to populate database
71- `settings` Settings management 71- `settings` Settings management
72- `ctl` Interactive shell with history and tab completion (like bluetoothctl)
72- `help` Print this message or the help of the given subcommand(s) 73- `help` Print this message or the help of the given subcommand(s)
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;