crabidy/cbd/src/main.rs

286 lines
11 KiB
Rust

//! `cbd`: server and TUI bundled into one binary
//! (see `architecture/cbd-bundle.md`).
//!
//! Starting `cbd` starts the crabidy server in-process, waits for it to
//! accept connections, and runs the TUI against it — the same configs,
//! the same localhost gRPC wire as the standalone pair. If a server is
//! already listening (a standalone `crabidy-server`), `cbd` adopts it
//! instead of failing. Quitting the TUI ends the process, and with it
//! the in-process server; the current queue is persisted continuously,
//! so the next start restores it.
use std::error::Error;
use std::sync::OnceLock;
use std::time::Duration;
use cbd_cli::{CbdCli, CbdCommand, RemoteCmd};
use cbd_tui::config::{self, Config};
use clap::{CommandFactory, Parser};
use crabidy_server::cli as server_cli;
use tracing::{info, warn};
/// The config file name for the bundled binary (separate from `cbd-tui.toml`).
const CONFIG_FILE: &str = "cbd.toml";
static CONFIG: OnceLock<Config> = OnceLock::new();
/// How long to wait for the server socket before giving up. Generous:
/// the first server start may run a provider login flow.
const READINESS_ATTEMPTS: u32 = 120;
const READINESS_DELAY: Duration = Duration::from_millis(500);
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// `cbd` is the union of the server and client command surfaces
// (architecture/cli.md D2): no subcommand runs the in-process server +
// TUI, exactly as before.
let cli = CbdCli::parse();
match cli.command {
None => run_bundle(cli).await,
Some(command) => {
if let Err(err) = run_command(&cli.remote, command).await {
eprintln!("error: {err}");
std::process::exit(1);
}
Ok(())
}
}
}
/// Runs the bundled server + TUI (the no-subcommand default), unchanged from
/// before save for the clap-based config load and flag overrides.
async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
// Both halves share one file-based subscriber: the terminal belongs
// to the TUI, so the server's usual stderr logging would corrupt it.
let _log_guard = init_tracing();
// Tracing is not the only writer, though. The audio stack runs *in this
// process* here, so ALSA's "underrun occurred" prints — and any panic
// message — would go straight onto the interface (cbd_tui::stderr).
let stderr_log = cbd_tui::stderr::log_dir().join("cbd.stderr.log");
if let Err(err) = cbd_tui::stderr::capture_into(&stderr_log) {
eprintln!(
"could not redirect stderr to {}: {err}",
stderr_log.display()
);
}
// `cbd` reads its OWN config (`cbd.toml`), separate from the
// standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on
// one machine — `cbd` self-contained against its in-process server,
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
// shared `address` would force one to follow the other. `cbd`
// defaults to localhost, which matches its embedded server.
let mut config = config::load_first_run(CONFIG_FILE);
config::apply_overrides(
&mut config,
cli.remote.address,
cli.remote.user,
cli.remote.password,
cli.spectrum,
);
let config = CONFIG.get_or_init(|| config);
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
let mut server = tokio::spawn(crabidy_server::serve(addr));
wait_for_server(
&config.server.address,
&mut server,
READINESS_ATTEMPTS,
READINESS_DELAY,
)
.await?;
cbd_tui::run(config).await
}
/// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic,
/// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a
/// server over gRPC.
async fn run_command(
remote: &cbd_cli::RemoteArgs,
command: CbdCommand,
) -> Result<(), Box<dyn Error>> {
match command {
CbdCommand::Guard(args) => server_cli::guard(args).await,
CbdCommand::Scan(args) => server_cli::scan(args).await,
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
CbdCommand::Features => server_cli::features(),
CbdCommand::Auth(args) => {
let password = args
.password
.ok_or("missing password (pass it as an argument)")?;
let path = config::write_auth(
CONFIG_FILE,
args.role.user_name(),
&password,
args.address.as_deref(),
)?;
println!(
"wrote credentials for {} to {}",
args.role.user_name(),
path.display()
);
Ok(())
}
CbdCommand::Library(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
}
CbdCommand::Queue(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
}
CbdCommand::Global(cmd) => {
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
}
CbdCommand::Completions(args) => {
cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd");
Ok(())
}
}
}
/// Resolves the connection for a remote command: the CLI flags win over
/// `cbd.toml`, which supplies the fallback (address and credentials).
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
let config = config::load_first_run(CONFIG_FILE);
cbd_cli::Connection {
address: remote.address.clone().unwrap_or(config.server.address),
user: remote.user.clone().unwrap_or(config.server.user),
password: remote.password.clone().unwrap_or(config.server.password),
}
}
/// Waits until something accepts TCP connections on the TUI's configured
/// server address (scheme stripped): the in-process server coming up, or
/// an already-running standalone one (in which case our `serve` fails
/// with the port taken and is deliberately ignored). Fails when the
/// in-process server dies while nothing is listening, or after
/// `attempts` polls.
async fn wait_for_server(
address: &str,
server: &mut tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
attempts: u32,
delay: Duration,
) -> Result<(), Box<dyn Error>> {
let host_port = address
.trim_start_matches("http://")
.trim_start_matches("https://")
.trim_end_matches('/');
for _ in 0..attempts {
if tokio::net::TcpStream::connect(host_port).await.is_ok() {
if server.is_finished() {
warn!("a server is already listening; connecting to it instead");
} else {
info!(address, "server is ready");
}
return Ok(());
}
if server.is_finished() {
// Nothing listening and our server is gone: a real failure
// (provider init, bad address), not an occupied port.
return match server.await {
Ok(Ok(())) => Err("the server exited before becoming ready".into()),
Ok(Err(err)) => Err(err.to_string().into()),
Err(err) => Err(err.to_string().into()),
};
}
tokio::time::sleep(delay).await;
}
Err(format!("no server reachable at {host_port} after {attempts} attempts").into())
}
/// Logs to a file (`crabidy/cbd.log` in the state dir), like `cbd-tui` —
/// but with the server crates' filter, since they run in-process here.
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = cbd_tui::stderr::log_dir();
if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"could not create log directory {}: {err}",
log_dir.display()
);
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,cbd=debug,cbd_tui=debug,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
)
});
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true),
)
.init();
Some(guard)
}
#[cfg(test)]
mod tests {
use super::*;
/// A server task that never finishes, standing in for a healthy
/// in-process server still starting up.
fn pending_server() -> tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> {
tokio::spawn(async {
std::future::pending::<()>().await;
Ok(())
})
}
#[tokio::test]
async fn readiness_polls_until_the_socket_accepts() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
// The scheme prefix must be stripped like the TUI config's URL.
let address = format!("http://{addr}/");
let mut server = pending_server();
wait_for_server(&address, &mut server, 10, Duration::from_millis(10))
.await
.expect("socket accepts");
server.abort();
}
#[tokio::test]
async fn readiness_gives_up_and_reports_a_dead_server() {
// Nothing listens on this address (bound, then dropped).
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
// A dead server task with nothing listening is a real failure.
let mut dead: tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>> =
tokio::spawn(async { Err("provider init failed".into()) });
let err = wait_for_server(
&format!("http://{addr}"),
&mut dead,
10,
Duration::from_millis(10),
)
.await
.expect_err("dead server surfaces");
assert!(err.to_string().contains("provider init failed"));
// A healthy-but-slow server just runs out of attempts.
let mut server = pending_server();
let err = wait_for_server(
&format!("http://{addr}"),
&mut server,
3,
Duration::from_millis(10),
)
.await
.expect_err("gives up eventually");
assert!(err.to_string().contains("after 3 attempts"));
server.abort();
}
}