mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user