feat(cli): add logs mode with tail/follow and completion support (#337)

Introduce a dedicated --logs mode for CLI log viewing with --tail <lines> and
-f/--follow streaming from the normal/debug log channel, keep user-facing CLI
output free of timestamped log formatting, and update bash/zsh completions for
the new logs flags.
This commit is contained in:
cucadmuh
2026-04-27 20:21:41 +03:00
committed by GitHub
parent 8967ca825d
commit bd2242e4f3
5 changed files with 242 additions and 62 deletions
+14 -1
View File
@@ -60,7 +60,7 @@ _psysonic_server_ids() {
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --player completions
--help --version --info --json --quiet --logs --player completions
}
integer i pidx=0
@@ -68,12 +68,25 @@ for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if [[ ${words[CURRENT-1]} == --tail ]]; then
_message -e descriptions 'number of lines'
return
fi
if (( pidx == 0 )); then
local has_logs=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --logs ]] && has_logs=1
done
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
if (( has_logs )); then
compadd -- --tail -f --follow
else
_psysonic_globals
fi
return
fi
+17 -1
View File
@@ -54,6 +54,14 @@ _psysonic_snapshot_json() {
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local prev=""
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ $prev == --tail ]]; then
_psysonic_compopt -o default
COMPREPLY=()
return
fi
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
@@ -61,11 +69,19 @@ _psysonic_complete() {
done
if (( pidx == 0 )); then
local has_logs=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
done
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
if (( has_logs )); then
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
else
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
fi
return
fi
+183 -58
View File
@@ -8,6 +8,10 @@ use std::path::PathBuf;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Read, Seek, SeekFrom},
};
use serde_json::Value;
use tauri::{AppHandle, Emitter, Runtime};
@@ -88,6 +92,41 @@ pub fn wants_info_json(args: &[String]) -> bool {
wants_info(args) && args.iter().skip(1).any(|a| a == "--json")
}
pub fn wants_tail(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "--tail")
}
pub fn wants_logs(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "--logs")
}
pub fn wants_follow(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "-f" || a == "--follow")
}
pub fn logs_tail_lines(args: &[String]) -> Result<Option<usize>, String> {
let mut i = 1usize;
while i < args.len() {
if args[i] == "--tail" {
let Some(raw) = args.get(i + 1) else {
return Err("--tail requires a numeric value".to_string());
};
if raw.starts_with('-') {
return Err("--tail requires a positive integer value".to_string());
}
let n: usize = raw
.parse()
.map_err(|_| "--tail requires a positive integer value".to_string())?;
if n == 0 {
return Err("--tail must be greater than 0".to_string());
}
return Ok(Some(n));
}
i += 1;
}
Ok(None)
}
pub fn wants_quiet(args: &[String]) -> bool {
args.iter()
.skip(1)
@@ -169,14 +208,14 @@ pub fn try_completions_dispatch(args: &[String]) -> Option<i32> {
Some(0)
}
Some(x) => {
crate::app_eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)");
eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)");
Some(2)
}
}
}
fn print_completions_install_help(program: &str) {
crate::app_eprintln!(
eprintln!(
"Psysonic embeds bash/zsh completion scripts in this binary.\n\
\n\
Bash — try once in this shell:\n\
@@ -213,58 +252,144 @@ pub fn write_cli_snapshot(payload: &Value) -> Result<(), String> {
pub fn print_help(program: &str) {
let version = env!("CARGO_PKG_VERSION");
crate::app_eprintln!("Psysonic {version}\n");
crate::app_eprintln!("── Start ──");
crate::app_eprintln!(" {program}");
crate::app_eprintln!(" {program} --version | -V Print version and exit.");
crate::app_eprintln!(" {program} --help | -h Show this help.\n");
crate::app_eprintln!("── Shell completion (scripts are embedded in the binary) ──");
crate::app_eprintln!(" {program} completions How to enable tab completion in bash / zsh.");
crate::app_eprintln!(" {program} completions bash Print bash completion script (stdout).");
crate::app_eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n");
crate::app_eprintln!("── Snapshot (saved play state / queue) ──");
crate::app_eprintln!(" Reads a JSON file written by the running app. Open the main window at least once.");
crate::app_eprintln!(" {program} --info Human-readable summary.");
crate::app_eprintln!(" {program} --info --json One JSON object on stdout.");
crate::app_eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus.");
crate::app_eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not");
crate::app_eprintln!(" published a snapshot yet.\n");
crate::app_eprintln!("── Remote commands (--player …) ──");
crate::app_eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS.");
crate::app_eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window.");
crate::app_eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n");
crate::app_eprintln!(" Global flags (place before --player when needed):");
crate::app_eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown).");
crate::app_eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout.");
crate::app_eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n");
crate::app_eprintln!(" Playback");
crate::app_eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop");
crate::app_eprintln!(" {program} [--quiet|-q] --player play <id> Track, album, or artist id (artist → shuffled library).");
crate::app_eprintln!(" {program} [--quiet|-q] --player seek <seconds> Integer delta, e.g. 15 or -10");
crate::app_eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.");
crate::app_eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue.");
crate::app_eprintln!(" {program} [--quiet|-q] --player repeat off|all|one");
crate::app_eprintln!(" {program} [--quiet|-q] --player mute | unmute");
crate::app_eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star).");
crate::app_eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears).");
crate::app_eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n");
crate::app_eprintln!(" Audio output");
crate::app_eprintln!(" {program} [--json] --player audio-device list");
crate::app_eprintln!(" {program} --player audio-device set <device-id|default>\n");
crate::app_eprintln!(" Music library (Subsonic music folders for the active server)");
crate::app_eprintln!(" {program} [--json] --player library list");
crate::app_eprintln!(" {program} --player library set all | <folder-id>\n");
crate::app_eprintln!(" Servers (saved profiles — same as the in-app server switcher)");
crate::app_eprintln!(" {program} [--json] --player server list");
crate::app_eprintln!(" {program} --player server set <server-id>\n");
crate::app_eprintln!(" Search (active server; respects library folder filter)");
crate::app_eprintln!(" {program} [--json] --player search track <query…>");
crate::app_eprintln!(" {program} [--json] --player search album <query…>");
crate::app_eprintln!(" {program} [--json] --player search artist <query…>\n");
crate::app_eprintln!(" Instant mix (from the track that is currently loaded)");
crate::app_eprintln!(" {program} --player mix append");
crate::app_eprintln!(" {program} --player mix new\n");
crate::app_eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status.");
eprintln!("Psysonic {version}\n");
eprintln!("── Start ──");
eprintln!(" {program}");
eprintln!(" {program} --version | -V Print version and exit.");
eprintln!(" {program} --help | -h Show this help.\n");
eprintln!("── Shell completion (scripts are embedded in the binary) ──");
eprintln!(" {program} completions How to enable tab completion in bash / zsh.");
eprintln!(" {program} completions bash Print bash completion script (stdout).");
eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n");
eprintln!("── Snapshot (saved play state / queue) ──");
eprintln!(" Reads a JSON file written by the running app. Open the main window at least once.");
eprintln!(" {program} --info Human-readable summary.");
eprintln!(" {program} --info --json One JSON object on stdout.");
eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus.");
eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not");
eprintln!(" published a snapshot yet.\n");
eprintln!("── Logs channel (normal + debug) ──");
eprintln!(" {program} --logs Print recent log lines and exit.");
eprintln!(" {program} --logs --tail <lines> Print the last <lines> entries.");
eprintln!(" {program} --logs --tail <lines> -f Keep streaming new lines.\n");
eprintln!("── Remote commands (--player …) ──");
eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS.");
eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window.");
eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n");
eprintln!(" Global flags (place before --player when needed):");
eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown).");
eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout.");
eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n");
eprintln!(" Playback");
eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop");
eprintln!(" {program} [--quiet|-q] --player play <id> Track, album, or artist id (artist → shuffled library).");
eprintln!(" {program} [--quiet|-q] --player seek <seconds> Integer delta, e.g. 15 or -10");
eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.");
eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue.");
eprintln!(" {program} [--quiet|-q] --player repeat off|all|one");
eprintln!(" {program} [--quiet|-q] --player mute | unmute");
eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star).");
eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears).");
eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n");
eprintln!(" Audio output");
eprintln!(" {program} [--json] --player audio-device list");
eprintln!(" {program} --player audio-device set <device-id|default>\n");
eprintln!(" Music library (Subsonic music folders for the active server)");
eprintln!(" {program} [--json] --player library list");
eprintln!(" {program} --player library set all | <folder-id>\n");
eprintln!(" Servers (saved profiles — same as the in-app server switcher)");
eprintln!(" {program} [--json] --player server list");
eprintln!(" {program} --player server set <server-id>\n");
eprintln!(" Search (active server; respects library folder filter)");
eprintln!(" {program} [--json] --player search track <query…>");
eprintln!(" {program} [--json] --player search album <query…>");
eprintln!(" {program} [--json] --player search artist <query…>\n");
eprintln!(" Instant mix (from the track that is currently loaded)");
eprintln!(" {program} --player mix append");
eprintln!(" {program} --player mix new\n");
eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status.");
}
const CLI_TAIL_DEFAULT_LINES: usize = 200;
fn print_log_tail_once(path: &std::path::Path, lines: usize) -> Result<u64, String> {
let file = std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
let mut ring: VecDeque<String> = VecDeque::with_capacity(lines.max(1));
let mut reader = BufReader::new(file);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
if ring.len() >= lines.max(1) {
ring.pop_front();
}
ring.push_back(line.trim_end_matches('\n').to_string());
}
for row in ring {
println!("{row}");
}
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
Ok(len)
}
fn follow_log_file(path: &std::path::Path, mut offset: u64) -> Result<(), String> {
loop {
let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if len < offset {
offset = 0;
}
if len > offset {
let mut f = std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?;
let mut chunk = String::new();
f.read_to_string(&mut chunk).map_err(|e| e.to_string())?;
if !chunk.is_empty() {
print!("{chunk}");
}
offset = len;
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Print from the shared normal/debug channel and exit.
pub fn run_tail_and_exit(args: &[String]) -> ! {
let tail_lines = match logs_tail_lines(args) {
Ok(Some(n)) => n,
Ok(None) => CLI_TAIL_DEFAULT_LINES,
Err(e) => {
eprintln!("NOT OK: {e}");
std::process::exit(2);
}
};
let path = crate::logging::cli_log_channel_path();
if !path.exists() {
eprintln!("NOT OK: no log channel file yet at {}", path.display());
std::process::exit(3);
}
let offset = match print_log_tail_once(&path, tail_lines) {
Ok(o) => o,
Err(e) => {
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
};
if wants_follow(args) {
if let Err(e) = follow_log_file(&path, offset) {
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
/// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`.
@@ -585,11 +710,11 @@ pub fn run_info_and_exit(args: &[String]) -> ! {
match linux_is_primary_instance_running() {
Ok(true) => {}
Ok(false) => {
crate::app_eprintln!("NOT OK: Psysonic is not running");
eprintln!("NOT OK: Psysonic is not running");
std::process::exit(2);
}
Err(e) => {
crate::app_eprintln!("NOT OK: {e}");
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
}
@@ -600,7 +725,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! {
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
let empty = v.is_null() || v.as_object().map(|m| m.is_empty()).unwrap_or(true);
if empty {
crate::app_eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded.");
eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded.");
std::process::exit(3);
}
@@ -608,7 +733,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! {
match serde_json::to_string(&v) {
Ok(line) => println!("{line}"),
Err(e) => {
crate::app_eprintln!("NOT OK: {e}");
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
}
+20 -1
View File
@@ -1,6 +1,7 @@
#[cfg(unix)]
use libc;
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, Ordering};
@@ -20,6 +21,16 @@ fn log_buffer() -> &'static Mutex<VecDeque<String>> {
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
}
/// Shared runtime file used by CLI `--tail` to read normal/debug log channel.
pub fn cli_log_channel_path() -> std::path::PathBuf {
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return std::path::PathBuf::from(dir).join("psysonic-cli.log");
}
}
std::env::temp_dir().join("psysonic-cli.log")
}
fn parse_logging_mode(mode: &str) -> Option<LoggingMode> {
match mode.trim().to_ascii_lowercase().as_str() {
"off" => Some(LoggingMode::Off),
@@ -57,7 +68,15 @@ pub fn append_log_line(line: String) {
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(line);
buf.push_back(line.clone());
drop(buf);
let path = cli_log_channel_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{}", line);
}
}
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
+7
View File
@@ -85,6 +85,13 @@ fn main() {
if psysonic_lib::cli::wants_info(&args) {
psysonic_lib::cli::run_info_and_exit(&args);
}
if psysonic_lib::cli::wants_logs(&args) {
psysonic_lib::cli::run_tail_and_exit(&args);
}
if psysonic_lib::cli::wants_tail(&args) {
eprintln!("NOT OK: --tail is only valid with --logs");
std::process::exit(2);
}
psysonic_lib::run();
}