286 lines
11 KiB
Rust
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();
|
|
}
|
|
}
|