use std::fs::OpenOptions;
use std::io::{self, Write as _};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::engine::{ArgValueCandidates, CompletionCandidate};
use clap_complete::env::CompleteEnv;
use koan_core::config;
use koan_core::db::connection::Database;
use koan_core::db::queries;
static LOGGER: OnceLock<BufferedLogger> = OnceLock::new();
struct BufferedLogger {
buffer: Mutex<Option<Arc<Mutex<Vec<String>>>>>,
log_file: Mutex<Option<std::fs::File>>,
}
impl BufferedLogger {
fn init() {
let log_path = config::config_dir().join("koan.log");
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let logger = LOGGER.get_or_init(|| BufferedLogger {
buffer: Mutex::new(None),
log_file: Mutex::new(log_file),
});
log::set_logger(logger).expect("failed to set logger");
log::set_max_level(log::LevelFilter::Info);
}
fn set_buffer(buf: Arc<Mutex<Vec<String>>>) {
if let Some(logger) = LOGGER.get() {
*logger.buffer.lock().unwrap() = Some(buf);
}
}
fn clear_buffer() {
if let Some(logger) = LOGGER.get() {
*logger.buffer.lock().unwrap() = None;
}
}
}
impl log::Log for BufferedLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let msg = format!(
"{}: {}",
record.level().as_str().to_lowercase(),
record.args()
);
if let Some(file) = self.log_file.lock().unwrap().as_mut() {
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let _ = writeln!(file, "[{}] {}", now, msg);
}
let module = record.module_path().unwrap_or("");
if record.level() == log::Level::Warn
&& (module.starts_with("lofty") || module.starts_with("symphonia"))
{
return;
}
if let Some(buf) = self.buffer.lock().unwrap().as_ref() {
buf.lock().unwrap().push(msg);
} else {
eprintln!("{}", msg);
}
}
fn flush(&self) {
if let Some(file) = self.log_file.lock().unwrap().as_mut() {
let _ = file.flush();
}
}
}
mod commands;
#[derive(Parser)]
#[command(name = "koan", about = "bit-perfect music player", version)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
headless: bool,
#[arg(short, long)]
daemonize: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
bind: Option<std::net::IpAddr>,
#[arg(long)]
subsonic: Option<u16>,
#[arg(long)]
no_api: bool,
#[arg(long)]
playground: bool,
}
#[derive(Subcommand)]
enum Commands {
Play {
paths: Vec<PathBuf>,
#[arg(long = "id", num_args = 1..)]
ids: Vec<i64>,
#[arg(long, add = ArgValueCandidates::new(complete_albums))]
album: Option<i64>,
#[arg(long, add = ArgValueCandidates::new(complete_artists))]
artist: Option<i64>,
#[arg(long, short = 'l')]
library: bool,
#[arg(long)]
clear: bool,
#[arg(long)]
server: Option<String>,
#[arg(long, requires = "server")]
jukebox: bool,
},
Mcp,
Scan {
path: Option<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
analyze: bool,
},
Analyze,
Search {
query: String,
},
Library,
Probe {
path: PathBuf,
},
Devices,
#[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)]
Config {
#[command(subcommand)]
command: Option<ConfigCommands>,
},
#[command(subcommand)]
Remote(RemoteCommands),
#[command(subcommand)]
Cache(CacheCommands),
#[command(subcommand)]
Auth(AuthCommands),
Completions {
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
enum ConfigCommands {
Init,
}
#[derive(Subcommand)]
enum RemoteCommands {
Login {
url: String,
username: String,
},
Sync {
#[arg(long)]
full: bool,
},
Status,
}
#[derive(Subcommand)]
enum CacheCommands {
Status,
Clear {
#[arg(long, short = 'y')]
yes: bool,
},
Evict,
}
#[derive(Subcommand)]
enum AuthCommands {
Setup,
CreateUser {
#[arg(long)]
username: String,
#[arg(long, default_value = "user")]
role: String,
},
DeleteUser {
username: String,
},
ListUsers,
Login {
#[arg(long, default_value = "http://127.0.0.1:4000")]
server: String,
#[arg(long)]
username: String,
},
Logout {
#[arg(long, default_value = "http://127.0.0.1:4000")]
server: String,
},
ResetPassword {
username: String,
},
SetRole {
username: String,
role: String,
},
RegenerateKeys,
Reset,
}
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
pub fn sigint_received() -> bool {
SIGINT_RECEIVED.load(Ordering::Relaxed)
}
fn main() {
ctrlc::set_handler(|| {
if SIGINT_RECEIVED.load(Ordering::Relaxed) {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::cursor::Show
);
std::process::exit(130);
}
SIGINT_RECEIVED.store(true, Ordering::Relaxed);
})
.ok();
CompleteEnv::with_factory(Cli::command).complete();
BufferedLogger::init();
let mut cli = Cli::parse();
let command = cli.command.take();
if cli.daemonize {
koan_server::graphql::cmd_serve_daemon(cli.port, cli.bind, cli.subsonic, cli.playground);
return;
}
if cli.headless {
koan_server::graphql::cmd_serve(cli.port, cli.bind, cli.subsonic, cli.playground);
return;
}
match command {
Some(Commands::Play {
paths,
ids,
album,
artist,
library,
clear,
server,
jukebox,
}) => {
start_player(
&cli, &paths, &ids, album, artist, library, clear, server, jukebox,
);
}
Some(Commands::Scan {
path,
force,
analyze,
}) => {
commands::cmd_scan(path.as_deref(), force);
if analyze {
commands::cmd_analyze();
}
}
Some(Commands::Mcp) => koan_server::mcp::cmd_mcp(),
Some(Commands::Analyze) => commands::cmd_analyze(),
Some(Commands::Search { query }) => commands::cmd_search(&query),
Some(Commands::Library) => commands::cmd_library(),
Some(Commands::Probe { path }) => commands::cmd_probe(&path),
Some(Commands::Devices) => commands::cmd_devices(),
Some(Commands::Config { command }) => match command {
Some(ConfigCommands::Init) => commands::cmd_init(),
None => commands::cmd_config(),
},
Some(Commands::Remote(sub)) => match sub {
RemoteCommands::Login { url, username } => commands::cmd_remote_login(&url, &username),
RemoteCommands::Sync { full } => commands::cmd_remote_sync(full),
RemoteCommands::Status => commands::cmd_remote_status(),
},
Some(Commands::Cache(sub)) => match sub {
CacheCommands::Status => commands::cmd_cache_status(),
CacheCommands::Clear { yes } => commands::cmd_cache_clear(yes),
CacheCommands::Evict => {
let cfg = config::Config::load().unwrap_or_default();
let freed = commands::evict_cache(&cfg, true);
if freed == 0 {
println!("cache within limit, nothing to evict");
}
}
},
Some(Commands::Auth(sub)) => match sub {
AuthCommands::Setup => commands::cmd_auth_setup(),
AuthCommands::CreateUser { username, role } => {
commands::cmd_auth_create_user(&username, &role);
}
AuthCommands::DeleteUser { username } => commands::cmd_auth_delete_user(&username),
AuthCommands::ListUsers => commands::cmd_auth_list_users(),
AuthCommands::Login { server, username } => {
commands::cmd_auth_login(&server, &username);
}
AuthCommands::Logout { server } => commands::cmd_auth_logout(&server),
AuthCommands::ResetPassword { username } => {
commands::cmd_auth_reset_password(&username);
}
AuthCommands::SetRole { username, role } => {
commands::cmd_auth_set_role(&username, &role);
}
AuthCommands::RegenerateKeys => commands::cmd_auth_regenerate_keys(),
AuthCommands::Reset => commands::cmd_auth_reset(),
},
Some(Commands::Completions { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), "koan", &mut io::stdout());
}
None => {
start_player(&cli, &[], &[], None, None, false, false, None, false);
}
}
}
#[allow(clippy::too_many_arguments)]
fn start_player(
cli: &Cli,
paths: &[PathBuf],
ids: &[i64],
album: Option<i64>,
artist: Option<i64>,
start_in_library: bool,
clear: bool,
server: Option<String>,
jukebox: bool,
) {
let cfg = koan_core::config::Config::load_or_default();
commands::evict_cache(&cfg, false);
if let Some(ref url) = server {
commands::cmd_play_remote(url, jukebox);
} else {
let api_enabled = !cli.no_api && cfg.graphql.enabled;
let api_opts = if api_enabled {
Some(commands::ApiOptions {
port: cli.port.or(Some(cfg.graphql.port)),
bind: cli.bind.or(Some(cfg.graphql.bind)),
subsonic: cli.subsonic.or(cfg.graphql.subsonic_port),
playground: cli.playground || cfg.graphql.playground,
})
} else {
None
};
commands::cmd_play(paths, ids, album, artist, start_in_library, clear, api_opts);
}
}
fn complete_artists() -> Vec<CompletionCandidate> {
let Ok(db) = Database::open_default() else {
return vec![];
};
let Ok(artists) = queries::all_artists(&db.conn) else {
return vec![];
};
artists
.into_iter()
.map(|a| CompletionCandidate::new(a.id.to_string()).help(Some(a.name.into())))
.collect()
}
fn complete_albums() -> Vec<CompletionCandidate> {
let Ok(db) = Database::open_default() else {
return vec![];
};
let Ok(albums) = queries::all_albums(&db.conn) else {
return vec![];
};
albums
.into_iter()
.map(|a| {
let label = format!("{} \u{2014} {}", a.artist_name, a.title);
CompletionCandidate::new(a.id.to_string()).help(Some(label.into()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn cli_no_args_parses() {
let cli = Cli::try_parse_from(["koan"]).unwrap();
assert!(cli.command.is_none());
assert!(!cli.headless);
}
#[test]
fn cli_play_subcommand_parses() {
let cli = Cli::try_parse_from(["koan", "play", "/tmp/test.flac"]).unwrap();
match cli.command {
Some(Commands::Play { ref paths, .. }) => {
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].to_str().unwrap(), "/tmp/test.flac");
}
_ => panic!("expected Play subcommand"),
}
}
#[test]
fn cli_play_with_flags_parses() {
let cli =
Cli::try_parse_from(["koan", "play", "--library", "--clear", "--id", "42"]).unwrap();
match cli.command {
Some(Commands::Play {
library,
clear,
ref ids,
..
}) => {
assert!(library);
assert!(clear);
assert_eq!(ids, &[42]);
}
_ => panic!("expected Play subcommand"),
}
}
#[test]
fn cli_headless_flag_on_root() {
let cli = Cli::try_parse_from(["koan", "--headless"]).unwrap();
assert!(cli.headless);
assert!(cli.command.is_none());
}
#[test]
fn cli_paths_on_root_rejected() {
let result = Cli::try_parse_from(["koan", "/tmp/test.flac"]);
assert!(result.is_err());
}
#[test]
fn cli_scan_still_works() {
let cli = Cli::try_parse_from(["koan", "scan", "--force"]).unwrap();
match cli.command {
Some(Commands::Scan { force, .. }) => assert!(force),
_ => panic!("expected Scan subcommand"),
}
}
#[test]
fn cli_play_server_requires_url() {
let cli =
Cli::try_parse_from(["koan", "play", "--server", "http://localhost:4000"]).unwrap();
match cli.command {
Some(Commands::Play { ref server, .. }) => {
assert_eq!(server.as_deref(), Some("http://localhost:4000"));
}
_ => panic!("expected Play subcommand"),
}
}
#[test]
fn cli_play_jukebox_requires_server() {
let result = Cli::try_parse_from(["koan", "play", "--jukebox"]);
assert!(result.is_err());
}
}