/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ use crate::config::Config; use crate::database::Database; use clap::{Command, CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; use std::path::PathBuf; use std::time::Duration; #[derive(Parser)] #[command(name = "rsp", infer_long_args = true)] #[command(about = "Rust music player daemon", long_about = None)] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand)] pub enum Commands { /// Start the daemon StartDaemon, /// Stop the daemon StopDaemon, /// Restart the daemon (kill existing and start new) Restart, /// Play a track, album, or artist Play { /// Add after current track and skip to it #[arg(short = 'i', long = "now")] now: bool, /// Add to queue after current track (don't skip) #[arg(short = 'n', long = "next")] next: bool, /// Queue the entire album containing this track (start at this track) #[arg(short = 'a', long = "album")] album: bool, #[arg(value_name = "QUERY")] query: Option, }, /// Pause playback Pause, /// Resume playback Resume, /// Toggle play/pause Toggle, /// Stop playback Stop, /// Skip to next track Next, /// Skip to previous track Previous, /// Set volume (0-100), or +N/-N for relative change Volume { #[arg(value_name = "LEVEL")] level: Option, }, /// Shuffle mode Shuffle { #[arg(value_name = "MODE")] mode: Option, }, /// Show the queue Queue, /// Show player status Status, /// Seek within the current track Seek { /// Position: `90`, `1:30`, `1:02:03`, or a relative offset `+15` / `-30`; /// without one, the current position is printed #[arg(value_name = "POSITION", allow_hyphen_values = true)] position: Option, }, /// Scan music paths to populate database Scan { #[arg(long)] force: bool, #[arg(long, value_name = "PATHS")] paths: Option>, }, /// Settings management Settings { #[command(subcommand)] command: SettingsCommands, }, #[clap(hide = true)] /// Generate shell completions (developer only) Completions { /// Shell to generate (bash, zsh, fish, elvish, powershell) /// If not specified, generates all completion files #[arg(value_name = "SHELL")] shell: Option, }, #[clap(hide = true)] /// Complete artists, albums, or tracks from database DbComplete { /// Query string (e.g. \"J\", \"Artist/\", \"Artist/Album/\") #[arg(value_name = "QUERY")] query: String, }, /// display lyrics Lyrics, } #[derive(Subcommand)] pub enum SettingsCommands { /// Get a setting value Get { #[arg(value_name = "KEY")] key: String, }, /// Set a setting value Set { #[arg(value_name = "KEY")] key: String, #[arg(value_name = "VALUE")] value: String, }, /// List all settings List, /// Unset a setting Unset { #[arg(value_name = "KEY")] key: String, }, /// Reload config from disk (re-reads config file, re-validates scrobbling tokens) Reload, } impl Cli { pub fn run(&self) -> Result<(), Box> { match &self.command { Commands::StartDaemon => { crate::daemon::start_daemon()?; } Commands::StopDaemon => { crate::daemon::stop_daemon()?; } Commands::Restart => { crate::daemon::restart_daemon()?; } Commands::Play { now, next, album, query, .. } => { if let Some(q) = query { let pos = if *now { "now" } else if *next { "next" } else { "last" }; if *album { crate::daemon::send_command(format!("play-album {} {}", pos, q))?; } else { crate::daemon::send_command(format!("play {} {}", pos, q))?; } } else { crate::daemon::send_command("play last".to_string())?; } } Commands::Pause => { crate::daemon::send_command("pause".to_string())?; } Commands::Resume => { crate::daemon::send_command("resume".to_string())?; } Commands::Toggle => { crate::daemon::send_command("toggle".to_string())?; } Commands::Stop => { crate::daemon::send_command("stop".to_string())?; } Commands::Next => { crate::daemon::send_command("next".to_string())?; } Commands::Previous => { crate::daemon::send_command("previous".to_string())?; } Commands::Volume { level } => { if let Some(l) = level { crate::daemon::send_command(format!("volume {}", l))?; } else { crate::daemon::send_command("volume".to_string())?; } } Commands::Shuffle { mode } => { if let Some(m) = mode { crate::daemon::send_command(format!("shuffle {}", m))?; } else { crate::daemon::send_command("shuffle".to_string())?; } } Commands::Queue => { crate::daemon::send_command("queue".to_string())?; } Commands::Status => { crate::daemon::send_command("status".to_string())?; } Commands::Seek { position } => match position { Some(p) => crate::daemon::send_command(format!("seek {}", p))?, None => crate::daemon::send_command("seek".to_string())?, }, Commands::Scan { force, paths } => { let config = Config::load()?; let db = Database::open(&config)?; if let Some(custom_paths) = paths { db.scan_paths(custom_paths, *force)?; } else { db.scan_paths(&config.music_paths, *force)?; } println!("Scan complete"); } Commands::Settings { command } => { let mut config = Config::load()?; match command { SettingsCommands::Get { key } => { println!("{}", config.get(key)?); } SettingsCommands::Set { key, value } => { config.set(key, value)?; config.save()?; if key == "listenbrainz_token" { let _ = crate::daemon::send_command("validate".to_string()); } } SettingsCommands::List => { config.list(); } SettingsCommands::Unset { key } => { config.unset(key)?; config.save()?; } SettingsCommands::Reload => { let _ = crate::daemon::send_command("reload".to_string()); } } } Commands::DbComplete { query } => { // Unescape shell escapes in the input query let query = { let mut unescaped = String::with_capacity(query.len()); let mut chars = query.chars(); while let Some(c) = chars.next() { if c == '\\' { if let Some(next) = chars.next() { unescaped.push(next); } } else { unescaped.push(c); } } unescaped }; // Empty query: synchronously query the DB directly (no timeout thread) // and never fall back to a directory listing, which would be confusing. if query.is_empty() { if let Ok(config) = Config::load() && let Ok(db) = Database::open(&config) { let results = db.complete("").unwrap_or_default(); for r in &results { println!("{r}"); } } return Ok(()); } // If query looks like a filesystem path, skip DB entirely if looks_like_path(&query) { for c in complete_path(&query) { println!("{c}"); } return Ok(()); } // Try DB completion with a timeout; if nothing returned, fall back // to filesystem path completion (e.g. empty library, no match). let db_results = db_complete_with_timeout(&query, Duration::from_millis(100)); if let Some(ref results) = db_results && !results.is_empty() { for r in results { println!("{r}"); } return Ok(()); } // Fallback: filesystem path completion for c in complete_path(&query) { println!("{c}"); } } Commands::Completions { shell } => { let full = Cli::command(); let subcommands: Vec = full .get_subcommands() .filter(|s| s.get_name() != "completions" && s.get_name() != "db-complete") .cloned() .collect(); let cmd_name = full.get_name().to_string(); let about = full.get_about().cloned(); let mut cmd = Command::new(cmd_name).infer_long_args(true); if let Some(a) = about { cmd = cmd.about(a); } for sc in subcommands { cmd = cmd.subcommand(sc); } if let Some(s) = shell { let shell = match s.as_str() { "bash" => Shell::Bash, "zsh" => Shell::Zsh, "fish" => Shell::Fish, "elvish" => Shell::Elvish, "powershell" => Shell::PowerShell, _ => { eprintln!("Unknown shell: {s}"); return Ok(()); } }; generate(shell, &mut cmd, "rsp", &mut std::io::stdout()); } else { std::fs::create_dir_all("completions/")?; let shells = [ (Shell::Bash, "bash"), (Shell::Zsh, "zsh"), (Shell::Fish, "fish"), (Shell::Elvish, "elvish"), (Shell::PowerShell, "powershell"), ]; for (shell, ext) in &shells { let path = format!("completions/rsp.{ext}"); let mut file = std::fs::File::create(&path)?; generate(*shell, &mut cmd, "rsp", &mut file); println!("Generated {path}"); } } } Commands::Lyrics => { let _ = crate::daemon::send_command("lyrics".to_string()); } } Ok(()) } } /// Returns true if the query looks like a filesystem path. fn looks_like_path(query: &str) -> bool { query.starts_with("./") || query.starts_with("../") || query.starts_with("~/") || query.starts_with('/') } /// List files/directories matching `query`, formatted as completion candidates. /// /// Directories get a trailing `/` so the shell knows they can be further expanded. fn complete_path(query: &str) -> Vec { if query.is_empty() { return Vec::new(); } // Split at the last '/' to separate directory and filename prefix let (dir_part, file_prefix) = match query.rfind('/') { Some(pos) => (&query[..=pos], &query[pos + 1..]), None => ("", query), }; // Resolve the directory to read. For ~/… we need the expanded path; // everything else works as-is from the CWD. let read_dir = if let Some(rest) = dir_part.strip_prefix("~/") { if let Some(home) = dirs::home_dir() { if rest.is_empty() { home } else { home.join(rest.trim_end_matches('/')) } } else { PathBuf::from(dir_part) } } else if dir_part.is_empty() { PathBuf::from(".") } else { PathBuf::from(dir_part) }; let mut results: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&read_dir) { for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy().to_string(); // Skip hidden entries unless the query explicitly asks for them if name_str.starts_with('.') && !file_prefix.starts_with('.') { continue; } if name_str == "." || name_str == ".." { continue; } if !name_str.starts_with(file_prefix) { continue; } let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); if is_dir { results.push(format!("{dir_part}{name_str}/")); } else { results.push(format!("{dir_part}{name_str}")); } } } results.sort(); results } /// Try the DB completion query in a separate thread with a timeout. /// /// Opens its own database connection so the caller doesn't need to keep one /// alive. Returns `None` on timeout or when the DB can't be opened. fn db_complete_with_timeout(query: &str, timeout: Duration) -> Option> { let query = query.to_string(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { if let Ok(config) = Config::load() && let Ok(db) = Database::open(&config) { let results = db.complete(&query).unwrap_or_default(); let _ = tx.send(results); } }); rx.recv_timeout(timeout).ok() }