Bundle server and TUI into a single cbd binary
crabidy-server and cbd-tui become libraries with thin mains: crabidy_server::serve(addr) hosts the whole server stack, cbd_tui::run(config) the client loops. The new cbd binary logs both halves to one file, starts the server in-process, waits for the socket (adopting an already-running standalone server on an occupied port), and runs the TUI against it over the unchanged localhost gRPC wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8032bec8e1
commit
58f7f9c66b
|
|
@ -448,6 +448,20 @@ dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbd"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cbd-tui",
|
||||||
|
"crabidy-core",
|
||||||
|
"crabidy-server",
|
||||||
|
"dirs",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbd-tui"
|
name = "cbd-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"audio-player",
|
"audio-player",
|
||||||
|
"cbd",
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
|
|
@ -66,7 +67,9 @@ url = "2"
|
||||||
|
|
||||||
# Local crates
|
# Local crates
|
||||||
audio-player = { path = "audio-player" }
|
audio-player = { path = "audio-player" }
|
||||||
|
cbd-tui = { path = "cbd-tui" }
|
||||||
crabidy-core = { path = "crabidy-core" }
|
crabidy-core = { path = "crabidy-core" }
|
||||||
|
crabidy-server = { path = "crabidy-server" }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
ytdy = { path = "ytdy" }
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
# cbd: bundled server + client binary
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
`crabidy-server` and `cbd-tui` are separate binaries: the normal setup
|
||||||
|
runs a long-lived server and attaches TUIs to it. The user wants a
|
||||||
|
single binary **`cbd`** for the one-machine case: starting it starts the
|
||||||
|
server and connects the TUI to it. Everything else works exactly the
|
||||||
|
same — same configs, same gRPC wire, same features.
|
||||||
|
|
||||||
|
## Assumptions (confirmed)
|
||||||
|
|
||||||
|
- `cbd-tui`'s config (`cbd-tui.toml`) already carries the server
|
||||||
|
address; the server listens on a constant (`0.0.0.0:50051`).
|
||||||
|
- Both mains are thin shells over module code: the server's `main.rs`
|
||||||
|
holds the command/message enums and the startup sequence; the TUI's
|
||||||
|
holds tracing setup and two loops (`orchestrate`, `run_ui`).
|
||||||
|
- The gRPC boundary stays: the bundled TUI talks to the in-process
|
||||||
|
server over localhost exactly like a remote one ("works the same
|
||||||
|
completely"). No in-process transport special-casing.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Both binaries become libraries with thin mains
|
||||||
|
|
||||||
|
- **crabidy-server**: `playback`, `provider`, `rpc` move from bin
|
||||||
|
modules to lib modules; the command/message enums and the startup
|
||||||
|
sequence move into the lib (`serve(addr)` builds orchestrator, queue
|
||||||
|
store, playback, rpc service and serves tonic on `addr`). `main.rs`
|
||||||
|
keeps only stderr tracing setup + `serve(LISTEN_ADDR)`.
|
||||||
|
- **cbd-tui**: gains `src/lib.rs` exposing `run(config)` (the two loops
|
||||||
|
and their channels); `main.rs` keeps file-based tracing setup +
|
||||||
|
config init + `run`.
|
||||||
|
- Behavior-preserving: no logic changes, only module moves and
|
||||||
|
`crabidy_server::` → `crate::` path rewrites. All existing tests move
|
||||||
|
along unchanged.
|
||||||
|
|
||||||
|
Not chosen: `cbd` spawning `crabidy-server` as a subprocess — that
|
||||||
|
needs the second binary installed, which is exactly what "a single
|
||||||
|
binary" is for.
|
||||||
|
|
||||||
|
### D2 — `cbd` = start (or adopt) the server, then run the TUI
|
||||||
|
|
||||||
|
New tiny binary crate `cbd`:
|
||||||
|
|
||||||
|
1. Tracing goes to the TUI's log file for **both** halves — the
|
||||||
|
terminal belongs to the TUI, so the server's stderr logging would
|
||||||
|
corrupt it.
|
||||||
|
2. Spawn `crabidy_server::serve(LISTEN_ADDR)` on a background task.
|
||||||
|
If the port is already taken (`AddrInUse` — a standalone server is
|
||||||
|
running), log and carry on: the TUI simply connects to the existing
|
||||||
|
server. Any other server error before readiness is fatal.
|
||||||
|
3. Wait for readiness by polling a TCP connect against the configured
|
||||||
|
server address (bounded retries with delay, then a clear error).
|
||||||
|
4. Run the TUI exactly as `cbd-tui` would, with the same
|
||||||
|
`cbd-tui.toml`.
|
||||||
|
|
||||||
|
Quitting the TUI ends the process — and with it the in-process server.
|
||||||
|
That is inherent to bundling and fine: the current queue is persisted
|
||||||
|
continuously, so the next start restores it (the ≤200 ms persist
|
||||||
|
debounce window is the same loss window as killing the standalone
|
||||||
|
server).
|
||||||
|
|
||||||
|
### D3 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- An in-process (channel) transport instead of localhost gRPC.
|
||||||
|
- Daemonizing: `cbd` never outlives its TUI. Users who want a
|
||||||
|
persistent server keep running `crabidy-server`.
|
||||||
|
- CLI subcommands (`cbd server`, `cbd attach`, …) — later if wanted.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
cbd: "cbd (one binary)" {
|
||||||
|
boot: "main: file tracing,\nspawn server, wait, run TUI"
|
||||||
|
srv: "crabidy-server lib\nserve(addr)"
|
||||||
|
tui: "cbd-tui lib\nrun(config)"
|
||||||
|
boot -> srv: "tokio::spawn\n(AddrInUse → adopt)"
|
||||||
|
boot -> tui: "after TCP readiness"
|
||||||
|
tui -> srv: "localhost gRPC\n(unchanged wire)"
|
||||||
|
}
|
||||||
|
|
||||||
|
standalone: "crabidy-server bin\n(unchanged)"
|
||||||
|
remote: "cbd-tui bin\n(unchanged)"
|
||||||
|
remote -> standalone: "gRPC (remote setup\nkeeps working)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **Port constant**: the server listens on `0.0.0.0:50051` and the TUI
|
||||||
|
config defaults to localhost; if the user points `cbd-tui.toml` at a
|
||||||
|
remote server, `cbd` still starts a local one (and connects to the
|
||||||
|
configured, remote one). Accepted: `cbd` is the one-machine tool.
|
||||||
|
- **Two log producers, one file**: server and TUI layers share the
|
||||||
|
bundled tracing subscriber; targets distinguish them.
|
||||||
|
- Open (future): a `--no-server` flag; graceful server shutdown (flush
|
||||||
|
the persister) on TUI exit.
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
//! The cbd-tui client as a library: the server-facing orchestration loop
|
||||||
|
//! and the terminal UI loop, exposed as [`run`] so both the standalone
|
||||||
|
//! `cbd-tui` binary and the bundled `cbd` binary can host them
|
||||||
|
//! (architecture/cbd-bundle.md D1). Tracing setup stays with the
|
||||||
|
//! binaries — where logs go is a hosting decision.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod config;
|
||||||
|
pub mod rpc;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
io,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use flume::{Receiver, Sender};
|
||||||
|
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use tokio::select;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use app::{bindings, App, DispatchResult, MessageFromUi, MessageToUi};
|
||||||
|
use config::Config;
|
||||||
|
use rpc::RpcClient;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Runs the client: the rpc orchestration loop on the runtime, the
|
||||||
|
/// blocking terminal UI on its own thread. Returns when the user quits
|
||||||
|
/// the UI.
|
||||||
|
pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
|
||||||
|
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
||||||
|
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
|
||||||
|
|
||||||
|
// FIXME: unwrap
|
||||||
|
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
run_ui(ui_tx, ui_rx);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn orchestrate(
|
||||||
|
config: &'static Config,
|
||||||
|
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
info!(address = config.server.address, "connecting to server");
|
||||||
|
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
||||||
|
|
||||||
|
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
||||||
|
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let init_data = rpc_client.init().await?;
|
||||||
|
info!("received initial state from server");
|
||||||
|
tx.send_async(MessageToUi::Init(init_data)).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
||||||
|
error!("request to server failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll(
|
||||||
|
rpc_client: &mut RpcClient,
|
||||||
|
rx: &Receiver<MessageFromUi>,
|
||||||
|
tx: &Sender<MessageToUi>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
select! {
|
||||||
|
Ok(msg) = &mut rx.recv_async() => {
|
||||||
|
match msg {
|
||||||
|
MessageFromUi::GetLibraryNode(path) => {
|
||||||
|
if let Some(node) = rpc_client.get_library_node(&path).await? {
|
||||||
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MessageFromUi::CreateNode { parent_path, title } => {
|
||||||
|
// Navigates the library into the created node on
|
||||||
|
// success; on failure the library stays where it is.
|
||||||
|
match rpc_client.create_library_node(&parent_path, &title).await {
|
||||||
|
Ok(node) => {
|
||||||
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(parent_path, title, "failed to create node: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MessageFromUi::RenameNode { path, new_title } => {
|
||||||
|
// Navigates the library into the renamed node on success;
|
||||||
|
// on failure the library stays where it is.
|
||||||
|
match rpc_client.rename_library_node(&path, &new_title).await {
|
||||||
|
Ok(node) => {
|
||||||
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(path, new_title, "failed to rename node: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MessageFromUi::DeleteNode { path } => {
|
||||||
|
// Shows the refreshed parent listing on success; on
|
||||||
|
// failure the library stays where it is.
|
||||||
|
match rpc_client.delete_library_node(&path).await {
|
||||||
|
Ok(parent) => {
|
||||||
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(parent.clone()));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(path, "failed to delete node: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MessageFromUi::AppendTracks(uuids) => {
|
||||||
|
rpc_client.append_tracks(uuids).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::QueueTracks(uuids) => {
|
||||||
|
rpc_client.queue_tracks(uuids).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::InsertTracks(uuids, pos) => {
|
||||||
|
rpc_client.insert_tracks(uuids, pos).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::RemoveTracks(positions) => {
|
||||||
|
rpc_client.remove_tracks(positions).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ReplaceQueue(uuids) => {
|
||||||
|
rpc_client.replace_queue(uuids).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::NextTrack => {
|
||||||
|
rpc_client.next_track().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::PrevTrack => {
|
||||||
|
rpc_client.prev_track().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::RestartTrack => {
|
||||||
|
rpc_client.restart_track().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::SetCurrentTrack(pos) => {
|
||||||
|
rpc_client.set_current_track(pos).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::TogglePlay => {
|
||||||
|
rpc_client.toggle_play().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ChangeVolume(delta) => {
|
||||||
|
rpc_client.change_volume(delta).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ToggleMute => {
|
||||||
|
rpc_client.toggle_mute().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ToggleShuffle => {
|
||||||
|
rpc_client.toggle_shuffle().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ToggleRepeat => {
|
||||||
|
rpc_client.toggle_repeat().await?
|
||||||
|
}
|
||||||
|
MessageFromUi::ClearQueue(exclude_current) => {
|
||||||
|
rpc_client.clear_queue(exclude_current).await?
|
||||||
|
}
|
||||||
|
MessageFromUi::SaveQueue(name) => {
|
||||||
|
// A rejected save (bad name, empty queue) must not tear
|
||||||
|
// down the poll loop; the server logs the cause.
|
||||||
|
if let Err(err) = rpc_client.save_queue(name.clone()).await {
|
||||||
|
error!(name, "failed to save queue: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageFromUi::CaptureNode { path, name, download } => {
|
||||||
|
// A rejected capture (bad name, over-cap subtree, failed
|
||||||
|
// download) must not tear down the poll loop either.
|
||||||
|
if let Err(err) = rpc_client
|
||||||
|
.capture_library_node(path.clone(), name.clone(), download)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(path, name, download, "failed to capture subtree: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(resp) = rpc_client.update_stream.next() => {
|
||||||
|
match resp {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Some(update) = resp.update {
|
||||||
|
tx.send_async(MessageToUi::Update(update)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("update stream broke, reconnecting: {err}");
|
||||||
|
rpc_client.reconnect_update_stream().await;
|
||||||
|
info!("update stream reconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
|
// setup terminal
|
||||||
|
enable_raw_mode().unwrap();
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
|
||||||
|
// create app and run it
|
||||||
|
let mut app = App::new(tx);
|
||||||
|
let tick_rate = Duration::from_millis(100);
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
for message in rx.try_iter() {
|
||||||
|
match message {
|
||||||
|
MessageToUi::ReplaceLibraryNode(node) => {
|
||||||
|
app.library.update(node);
|
||||||
|
}
|
||||||
|
MessageToUi::Init(init_data) => {
|
||||||
|
if let Some(queue) = init_data.queue {
|
||||||
|
app.queue.update_queue(queue);
|
||||||
|
}
|
||||||
|
if let Some(track) = init_data.queue_track {
|
||||||
|
app.now_playing.update_track(track.track);
|
||||||
|
app.queue.update_position(track.queue_position as usize);
|
||||||
|
}
|
||||||
|
if let Ok(ps) = PlayState::try_from(init_data.play_state) {
|
||||||
|
app.now_playing.update_play_state(ps);
|
||||||
|
}
|
||||||
|
if let Some(mods) = init_data.mods {
|
||||||
|
app.now_playing.update_modifiers(&mods);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageToUi::Update(update) => match update {
|
||||||
|
StreamUpdate::Queue(queue) => {
|
||||||
|
app.queue.update_queue(queue);
|
||||||
|
}
|
||||||
|
StreamUpdate::QueueTrack(track) => {
|
||||||
|
app.now_playing.update_track(track.track);
|
||||||
|
app.queue.update_position(track.queue_position as usize);
|
||||||
|
}
|
||||||
|
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
|
||||||
|
StreamUpdate::PlayState(play_state) => {
|
||||||
|
if let Ok(ps) = PlayState::try_from(play_state) {
|
||||||
|
app.now_playing.update_play_state(ps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StreamUpdate::Mods(mods) => {
|
||||||
|
app.now_playing.update_modifiers(&mods);
|
||||||
|
}
|
||||||
|
StreamUpdate::Mute(_) => { /* FIXME: implement */ }
|
||||||
|
StreamUpdate::Volume(_) => { /* FIXME: implement */ }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = terminal.draw(|f| app.render(f)) {
|
||||||
|
error!("failed to draw frame: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = tick_rate
|
||||||
|
.checked_sub(last_tick.elapsed())
|
||||||
|
.unwrap_or_else(|| Duration::from_secs(0));
|
||||||
|
|
||||||
|
if event::poll(timeout).unwrap() {
|
||||||
|
if let Event::Key(key) = event::read().unwrap() {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
// The input overlay is strictly modal: while it is open,
|
||||||
|
// keys edit the buffer and the bindings table (including
|
||||||
|
// quit) is unreachable.
|
||||||
|
if app.input.is_some() {
|
||||||
|
app.handle_input_key(key);
|
||||||
|
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
||||||
|
if app.dispatch(action) == DispatchResult::Quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_tick.elapsed() >= tick_rate {
|
||||||
|
last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restore terminal
|
||||||
|
disable_raw_mode().unwrap();
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
terminal.show_cursor().unwrap();
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
mod app;
|
//! The standalone TUI binary: file-based tracing (the terminal belongs
|
||||||
mod config;
|
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives
|
||||||
mod rpc;
|
//! in the library so the bundled `cbd` binary can host it too
|
||||||
|
//! (architecture/cbd-bundle.md D1).
|
||||||
|
|
||||||
use std::{
|
use std::sync::OnceLock;
|
||||||
error::Error,
|
|
||||||
io,
|
|
||||||
sync::OnceLock,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState};
|
use cbd_tui::config::Config;
|
||||||
|
|
||||||
use crossterm::{
|
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use flume::{Receiver, Sender};
|
|
||||||
|
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
||||||
use tokio::select;
|
|
||||||
use tokio_stream::StreamExt;
|
|
||||||
|
|
||||||
use app::{bindings, App, DispatchResult, MessageFromUi, MessageToUi};
|
|
||||||
use config::Config;
|
|
||||||
use rpc::RpcClient;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
|
|
@ -65,271 +45,5 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
||||||
|
cbd_tui::run(config).await
|
||||||
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
|
||||||
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
|
|
||||||
|
|
||||||
// FIXME: unwrap
|
|
||||||
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(|| {
|
|
||||||
run_ui(ui_tx, ui_rx);
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn orchestrate(
|
|
||||||
config: &'static Config,
|
|
||||||
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
info!(address = config.server.address, "connecting to server");
|
|
||||||
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
|
||||||
|
|
||||||
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
|
||||||
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let init_data = rpc_client.init().await?;
|
|
||||||
info!("received initial state from server");
|
|
||||||
tx.send_async(MessageToUi::Init(init_data)).await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
|
||||||
error!("request to server failed: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll(
|
|
||||||
rpc_client: &mut RpcClient,
|
|
||||||
rx: &Receiver<MessageFromUi>,
|
|
||||||
tx: &Sender<MessageToUi>,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
select! {
|
|
||||||
Ok(msg) = &mut rx.recv_async() => {
|
|
||||||
match msg {
|
|
||||||
MessageFromUi::GetLibraryNode(path) => {
|
|
||||||
if let Some(node) = rpc_client.get_library_node(&path).await? {
|
|
||||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MessageFromUi::CreateNode { parent_path, title } => {
|
|
||||||
// Navigates the library into the created node on
|
|
||||||
// success; on failure the library stays where it is.
|
|
||||||
match rpc_client.create_library_node(&parent_path, &title).await {
|
|
||||||
Ok(node) => {
|
|
||||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(parent_path, title, "failed to create node: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MessageFromUi::RenameNode { path, new_title } => {
|
|
||||||
// Navigates the library into the renamed node on success;
|
|
||||||
// on failure the library stays where it is.
|
|
||||||
match rpc_client.rename_library_node(&path, &new_title).await {
|
|
||||||
Ok(node) => {
|
|
||||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(path, new_title, "failed to rename node: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MessageFromUi::DeleteNode { path } => {
|
|
||||||
// Shows the refreshed parent listing on success; on
|
|
||||||
// failure the library stays where it is.
|
|
||||||
match rpc_client.delete_library_node(&path).await {
|
|
||||||
Ok(parent) => {
|
|
||||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(parent.clone()));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(path, "failed to delete node: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MessageFromUi::AppendTracks(uuids) => {
|
|
||||||
rpc_client.append_tracks(uuids).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::QueueTracks(uuids) => {
|
|
||||||
rpc_client.queue_tracks(uuids).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::InsertTracks(uuids, pos) => {
|
|
||||||
rpc_client.insert_tracks(uuids, pos).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::RemoveTracks(positions) => {
|
|
||||||
rpc_client.remove_tracks(positions).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ReplaceQueue(uuids) => {
|
|
||||||
rpc_client.replace_queue(uuids).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::NextTrack => {
|
|
||||||
rpc_client.next_track().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::PrevTrack => {
|
|
||||||
rpc_client.prev_track().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::RestartTrack => {
|
|
||||||
rpc_client.restart_track().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::SetCurrentTrack(pos) => {
|
|
||||||
rpc_client.set_current_track(pos).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::TogglePlay => {
|
|
||||||
rpc_client.toggle_play().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ChangeVolume(delta) => {
|
|
||||||
rpc_client.change_volume(delta).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ToggleMute => {
|
|
||||||
rpc_client.toggle_mute().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ToggleShuffle => {
|
|
||||||
rpc_client.toggle_shuffle().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ToggleRepeat => {
|
|
||||||
rpc_client.toggle_repeat().await?
|
|
||||||
}
|
|
||||||
MessageFromUi::ClearQueue(exclude_current) => {
|
|
||||||
rpc_client.clear_queue(exclude_current).await?
|
|
||||||
}
|
|
||||||
MessageFromUi::SaveQueue(name) => {
|
|
||||||
// A rejected save (bad name, empty queue) must not tear
|
|
||||||
// down the poll loop; the server logs the cause.
|
|
||||||
if let Err(err) = rpc_client.save_queue(name.clone()).await {
|
|
||||||
error!(name, "failed to save queue: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessageFromUi::CaptureNode { path, name, download } => {
|
|
||||||
// A rejected capture (bad name, over-cap subtree, failed
|
|
||||||
// download) must not tear down the poll loop either.
|
|
||||||
if let Err(err) = rpc_client
|
|
||||||
.capture_library_node(path.clone(), name.clone(), download)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(path, name, download, "failed to capture subtree: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(resp) = rpc_client.update_stream.next() => {
|
|
||||||
match resp {
|
|
||||||
Ok(resp) => {
|
|
||||||
if let Some(update) = resp.update {
|
|
||||||
tx.send_async(MessageToUi::Update(update)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!("update stream broke, reconnecting: {err}");
|
|
||||||
rpc_client.reconnect_update_stream().await;
|
|
||||||
info!("update stream reconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
|
||||||
// setup terminal
|
|
||||||
enable_raw_mode().unwrap();
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
|
||||||
|
|
||||||
// create app and run it
|
|
||||||
let mut app = App::new(tx);
|
|
||||||
let tick_rate = Duration::from_millis(100);
|
|
||||||
let mut last_tick = Instant::now();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
for message in rx.try_iter() {
|
|
||||||
match message {
|
|
||||||
MessageToUi::ReplaceLibraryNode(node) => {
|
|
||||||
app.library.update(node);
|
|
||||||
}
|
|
||||||
MessageToUi::Init(init_data) => {
|
|
||||||
if let Some(queue) = init_data.queue {
|
|
||||||
app.queue.update_queue(queue);
|
|
||||||
}
|
|
||||||
if let Some(track) = init_data.queue_track {
|
|
||||||
app.now_playing.update_track(track.track);
|
|
||||||
app.queue.update_position(track.queue_position as usize);
|
|
||||||
}
|
|
||||||
if let Ok(ps) = PlayState::try_from(init_data.play_state) {
|
|
||||||
app.now_playing.update_play_state(ps);
|
|
||||||
}
|
|
||||||
if let Some(mods) = init_data.mods {
|
|
||||||
app.now_playing.update_modifiers(&mods);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessageToUi::Update(update) => match update {
|
|
||||||
StreamUpdate::Queue(queue) => {
|
|
||||||
app.queue.update_queue(queue);
|
|
||||||
}
|
|
||||||
StreamUpdate::QueueTrack(track) => {
|
|
||||||
app.now_playing.update_track(track.track);
|
|
||||||
app.queue.update_position(track.queue_position as usize);
|
|
||||||
}
|
|
||||||
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
|
|
||||||
StreamUpdate::PlayState(play_state) => {
|
|
||||||
if let Ok(ps) = PlayState::try_from(play_state) {
|
|
||||||
app.now_playing.update_play_state(ps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StreamUpdate::Mods(mods) => {
|
|
||||||
app.now_playing.update_modifiers(&mods);
|
|
||||||
}
|
|
||||||
StreamUpdate::Mute(_) => { /* FIXME: implement */ }
|
|
||||||
StreamUpdate::Volume(_) => { /* FIXME: implement */ }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = terminal.draw(|f| app.render(f)) {
|
|
||||||
error!("failed to draw frame: {err}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = tick_rate
|
|
||||||
.checked_sub(last_tick.elapsed())
|
|
||||||
.unwrap_or_else(|| Duration::from_secs(0));
|
|
||||||
|
|
||||||
if event::poll(timeout).unwrap() {
|
|
||||||
if let Event::Key(key) = event::read().unwrap() {
|
|
||||||
if key.kind == KeyEventKind::Press {
|
|
||||||
// The input overlay is strictly modal: while it is open,
|
|
||||||
// keys edit the buffer and the bindings table (including
|
|
||||||
// quit) is unreachable.
|
|
||||||
if app.input.is_some() {
|
|
||||||
app.handle_input_key(key);
|
|
||||||
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
|
||||||
if app.dispatch(action) == DispatchResult::Quit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_tick.elapsed() >= tick_rate {
|
|
||||||
last_tick = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore terminal
|
|
||||||
disable_raw_mode().unwrap();
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
terminal.show_cursor().unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "cbd"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cbd-tui.workspace = true
|
||||||
|
crabidy-core.workspace = true
|
||||||
|
crabidy-server.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-appender.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
//! `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_tui::config::Config;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
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>> {
|
||||||
|
// 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();
|
||||||
|
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = dirs::state_dir()
|
||||||
|
.or_else(dirs::cache_dir)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("crabidy");
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,132 @@
|
||||||
pub mod bookmark_store;
|
pub mod bookmark_store;
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod capture_store;
|
pub mod capture_store;
|
||||||
|
pub mod playback;
|
||||||
|
pub mod provider;
|
||||||
pub mod queue_store;
|
pub mod queue_store;
|
||||||
|
pub mod rpc;
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{Queue, Track};
|
use audio_player::PlayerMessage;
|
||||||
|
use crabidy_core::proto::crabidy::{
|
||||||
|
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||||
|
Track,
|
||||||
|
};
|
||||||
|
use crabidy_core::{ProviderClient, ProviderError};
|
||||||
use rand::{rng, seq::SliceRandom};
|
use rand::{rng, seq::SliceRandom};
|
||||||
use std::sync::{atomic::AtomicBool, Arc};
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error, info, instrument, warn, Span};
|
||||||
|
|
||||||
|
/// The gRPC listen address of the server.
|
||||||
|
pub const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||||
|
|
||||||
|
/// Builds and runs the whole server stack on `addr`: provider
|
||||||
|
/// orchestrator, queue persistence, playback loop, player message
|
||||||
|
/// forwarder, and the tonic gRPC service. Runs until the server is shut
|
||||||
|
/// down or fails.
|
||||||
|
///
|
||||||
|
/// Extracted from the `crabidy-server` binary so the bundled `cbd`
|
||||||
|
/// binary can host the same server in-process
|
||||||
|
/// (architecture/cbd-bundle.md D1). Errors are returned, never
|
||||||
|
/// panicked: a failed provider init or an occupied port is the
|
||||||
|
/// caller's decision.
|
||||||
|
pub async fn serve(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||||
|
let orchestrator = provider::ProviderOrchestrator::init("")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("failed to init provider orchestrator: {err}");
|
||||||
|
err
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Queue persistence is optional: without a usable queues directory the
|
||||||
|
// server runs with an in-memory queue only.
|
||||||
|
let queue_store = match queue_store::queues_dir() {
|
||||||
|
Some(dir) => match queue_store::QueueStore::open(dir).await {
|
||||||
|
Ok(store) => Some(Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("queue persistence disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("queue persistence disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let playback = playback::Playback::new(
|
||||||
|
update_tx.clone(),
|
||||||
|
orchestrator.provider_tx.clone(),
|
||||||
|
queue_store,
|
||||||
|
);
|
||||||
|
// Reload the persisted current queue before anything can observe or
|
||||||
|
// mutate state; never starts playback.
|
||||||
|
playback.restore_current().await;
|
||||||
|
|
||||||
|
let playback_tx = playback.playback_tx.clone();
|
||||||
|
let player_msg = playback.player.messages.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
poll_play_bus(player_msg, playback_tx);
|
||||||
|
});
|
||||||
|
info!("player message forwarder started");
|
||||||
|
|
||||||
|
let crabidy_service = rpc::RpcService::new(
|
||||||
|
update_tx,
|
||||||
|
playback.playback_tx.clone(),
|
||||||
|
orchestrator.provider_tx.clone(),
|
||||||
|
);
|
||||||
|
orchestrator.run();
|
||||||
|
info!("provider orchestrator started");
|
||||||
|
playback.run();
|
||||||
|
info!("playback started");
|
||||||
|
|
||||||
|
info!(%addr, "grpc server listening");
|
||||||
|
tonic::transport::Server::builder()
|
||||||
|
.add_service(CrabidyServiceServer::new(crabidy_service))
|
||||||
|
.serve(addr)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards player engine events into the playback message loop.
|
||||||
|
#[instrument(skip(rx, tx))]
|
||||||
|
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||||
|
for msg in rx.iter() {
|
||||||
|
let command = match msg {
|
||||||
|
PlayerMessage::EndOfStream => {
|
||||||
|
debug!("player reported end of stream");
|
||||||
|
PlaybackCommand::Next
|
||||||
|
}
|
||||||
|
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Stopped,
|
||||||
|
},
|
||||||
|
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Paused,
|
||||||
|
},
|
||||||
|
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||||||
|
state: PlayState::Playing,
|
||||||
|
},
|
||||||
|
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||||||
|
duration: duration.as_millis() as u32,
|
||||||
|
position: elapsed.as_millis() as u32,
|
||||||
|
},
|
||||||
|
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||||||
|
duration: duration.as_millis() as u32,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||||||
|
error!("failed to forward player message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn!("player message channel closed");
|
||||||
|
}
|
||||||
|
|
||||||
/// How a pending queue operation places its resolved chunks.
|
/// How a pending queue operation places its resolved chunks.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -554,3 +673,210 @@ mod tests {
|
||||||
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||||
|
/// was current when it was sent so the handler can attribute its events to
|
||||||
|
/// the originating request.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProviderMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: ProviderCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderMessage {
|
||||||
|
pub fn new(command: ProviderCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ProviderCommand {
|
||||||
|
GetLibraryNode {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
GetTrackUrls {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Resolves a path into playable tracks: a track path yields that single
|
||||||
|
/// track, a node path yields all tracks reachable below it. Streamed:
|
||||||
|
/// zero or more in-order chunks arrive on `chunk_tx`, the sender is
|
||||||
|
/// dropped when resolution finishes, and dropping the receiver cancels
|
||||||
|
/// it (see `ProviderClient::resolve_tracks_into`). The orchestrator
|
||||||
|
/// handles this command on a spawned task so its loop stays free for
|
||||||
|
/// other commands (notably `GetTrackUrls` for the first chunk's track).
|
||||||
|
ResolveTracks {
|
||||||
|
path: String,
|
||||||
|
chunk_tx: flume::Sender<Vec<Track>>,
|
||||||
|
},
|
||||||
|
/// Creates a child under a creatable node (see
|
||||||
|
/// `ProviderClient::create_lib_node`); replies with the created node.
|
||||||
|
CreateLibraryNode {
|
||||||
|
parent_path: String,
|
||||||
|
title: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Renames an editable node (see `ProviderClient::rename_lib_node`);
|
||||||
|
/// replies with the renamed node at its new path.
|
||||||
|
RenameLibraryNode {
|
||||||
|
path: String,
|
||||||
|
new_title: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Deletes a deletable node (see `ProviderClient::delete_lib_node`);
|
||||||
|
/// replies with the refreshed parent node.
|
||||||
|
DeleteLibraryNode {
|
||||||
|
path: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
|
/// Captures the queueable subtree at `path` as the bookmark `name`
|
||||||
|
/// (see `architecture/bookmarks.md` D1–D3) — or, with `download`, as
|
||||||
|
/// the download capture `name` under `/captures`, fetching every
|
||||||
|
/// track's audio (see `architecture/captures.md`). Handled on a
|
||||||
|
/// spawned task — a large walk or download must not block the
|
||||||
|
/// orchestrator loop.
|
||||||
|
CaptureLibraryNode {
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
download: bool,
|
||||||
|
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::GetLibraryNode { .. } => "get_library_node",
|
||||||
|
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||||
|
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||||
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
|
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||||
|
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||||
|
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PlaybackMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: PlaybackCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackMessage {
|
||||||
|
pub fn new(command: PlaybackCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlaybackCommand {
|
||||||
|
Init {
|
||||||
|
result_tx: flume::Sender<InitResponse>,
|
||||||
|
},
|
||||||
|
Replace {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Queue {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Append {
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
positions: Vec<u32>,
|
||||||
|
},
|
||||||
|
Insert {
|
||||||
|
position: u32,
|
||||||
|
paths: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Internal: a resolved chunk of tracks for the pending queue operation
|
||||||
|
/// `op_id`, sent by that operation's forwarder task. Chunks for an
|
||||||
|
/// unknown (finished or cancelled) op are dropped silently.
|
||||||
|
ApplyResolvedChunk {
|
||||||
|
op_id: u64,
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
},
|
||||||
|
/// Internal: the forwarder task for `op_id` has seen the provider drop
|
||||||
|
/// its chunk sender — the operation is complete and the `resolving`
|
||||||
|
/// flag clears once no pending operations remain.
|
||||||
|
ResolveFinished {
|
||||||
|
op_id: u64,
|
||||||
|
},
|
||||||
|
Clear {
|
||||||
|
exclude_current: bool,
|
||||||
|
},
|
||||||
|
SetCurrent {
|
||||||
|
position: u32,
|
||||||
|
},
|
||||||
|
/// Saves the current queue under a name (see
|
||||||
|
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||||
|
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||||
|
/// reports through `result_tx`.
|
||||||
|
SaveQueue {
|
||||||
|
name: String,
|
||||||
|
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
|
||||||
|
},
|
||||||
|
ToggleShuffle,
|
||||||
|
ToggleRepeat,
|
||||||
|
TogglePlay,
|
||||||
|
Stop,
|
||||||
|
ChangeVolume {
|
||||||
|
delta: f32,
|
||||||
|
},
|
||||||
|
ToggleMute,
|
||||||
|
Next,
|
||||||
|
Prev,
|
||||||
|
RestartTrack,
|
||||||
|
StateChanged {
|
||||||
|
state: PlayState,
|
||||||
|
},
|
||||||
|
VolumeChanged {
|
||||||
|
volume: f32,
|
||||||
|
},
|
||||||
|
MuteChanged {
|
||||||
|
muted: bool,
|
||||||
|
},
|
||||||
|
PositionChanged {
|
||||||
|
duration: u32,
|
||||||
|
position: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Init { .. } => "init",
|
||||||
|
Self::Replace { .. } => "replace",
|
||||||
|
Self::Queue { .. } => "queue",
|
||||||
|
Self::Append { .. } => "append",
|
||||||
|
Self::Remove { .. } => "remove",
|
||||||
|
Self::Insert { .. } => "insert",
|
||||||
|
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||||||
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
|
Self::Clear { .. } => "clear",
|
||||||
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
|
Self::TogglePlay => "toggle_play",
|
||||||
|
Self::Stop => "stop",
|
||||||
|
Self::ChangeVolume { .. } => "change_volume",
|
||||||
|
Self::ToggleMute => "toggle_mute",
|
||||||
|
Self::Next => "next",
|
||||||
|
Self::Prev => "prev",
|
||||||
|
Self::RestartTrack => "restart_track",
|
||||||
|
Self::StateChanged { .. } => "state_changed",
|
||||||
|
Self::VolumeChanged { .. } => "volume_changed",
|
||||||
|
Self::MuteChanged { .. } => "mute_changed",
|
||||||
|
Self::PositionChanged { .. } => "position_changed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,14 @@
|
||||||
use audio_player::PlayerMessage;
|
//! The standalone server binary: stderr tracing plus
|
||||||
use crabidy_core::proto::crabidy::{
|
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack
|
||||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
//! lives in the library so the bundled `cbd` binary can host it too
|
||||||
};
|
//! (architecture/cbd-bundle.md D1).
|
||||||
use crabidy_core::{ProviderClient, ProviderError};
|
|
||||||
use tracing::{debug, error, info, instrument, warn, Span};
|
|
||||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
mod playback;
|
|
||||||
use playback::Playback;
|
|
||||||
mod provider;
|
|
||||||
use provider::ProviderOrchestrator;
|
|
||||||
mod rpc;
|
|
||||||
use rpc::RpcService;
|
|
||||||
|
|
||||||
use tonic::{transport::Server, Result};
|
|
||||||
|
|
||||||
const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
|
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
|
||||||
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
|
|
||||||
error!("failed to init provider orchestrator: {err}");
|
|
||||||
err
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Queue persistence is optional: without a usable queues directory the
|
|
||||||
// server runs with an in-memory queue only.
|
|
||||||
let queue_store = match crabidy_server::queue_store::queues_dir() {
|
|
||||||
Some(dir) => match crabidy_server::queue_store::QueueStore::open(dir).await {
|
|
||||||
Ok(store) => Some(std::sync::Arc::new(store)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!("queue persistence disabled: {err}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
warn!("queue persistence disabled: no config directory");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let playback = Playback::new(
|
|
||||||
update_tx.clone(),
|
|
||||||
orchestrator.provider_tx.clone(),
|
|
||||||
queue_store,
|
|
||||||
);
|
|
||||||
// Reload the persisted current queue before anything can observe or
|
|
||||||
// mutate state; never starts playback.
|
|
||||||
playback.restore_current().await;
|
|
||||||
|
|
||||||
let playback_tx = playback.playback_tx.clone();
|
|
||||||
let player_msg = playback.player.messages.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(|| {
|
|
||||||
poll_play_bus(player_msg, playback_tx);
|
|
||||||
});
|
|
||||||
info!("player message forwarder started");
|
|
||||||
|
|
||||||
let crabidy_service = RpcService::new(
|
|
||||||
update_tx,
|
|
||||||
playback.playback_tx.clone(),
|
|
||||||
orchestrator.provider_tx.clone(),
|
|
||||||
);
|
|
||||||
orchestrator.run();
|
|
||||||
info!("provider orchestrator started");
|
|
||||||
playback.run();
|
|
||||||
info!("playback started");
|
|
||||||
|
|
||||||
let addr = LISTEN_ADDR.parse()?;
|
|
||||||
info!(%addr, "grpc server listening");
|
|
||||||
Server::builder()
|
|
||||||
.add_service(CrabidyServiceServer::new(crabidy_service))
|
|
||||||
.serve(addr)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +22,7 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
|
||||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
EnvFilter::new(
|
EnvFilter::new(
|
||||||
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
|
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug",
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,246 +41,3 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
|
||||||
guard
|
guard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwards player engine events into the playback message loop.
|
|
||||||
#[instrument(skip(rx, tx))]
|
|
||||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
|
||||||
for msg in rx.iter() {
|
|
||||||
let command = match msg {
|
|
||||||
PlayerMessage::EndOfStream => {
|
|
||||||
debug!("player reported end of stream");
|
|
||||||
PlaybackCommand::Next
|
|
||||||
}
|
|
||||||
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Stopped,
|
|
||||||
},
|
|
||||||
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Paused,
|
|
||||||
},
|
|
||||||
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
|
||||||
state: PlayState::Playing,
|
|
||||||
},
|
|
||||||
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
|
||||||
position: elapsed.as_millis() as u32,
|
|
||||||
},
|
|
||||||
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
|
||||||
position: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
|
||||||
error!("failed to forward player message: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
warn!("player message channel closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command for the provider orchestrator, tagged with the tracing span that
|
|
||||||
/// was current when it was sent so the handler can attribute its events to
|
|
||||||
/// the originating request.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ProviderMessage {
|
|
||||||
pub span: Span,
|
|
||||||
pub command: ProviderCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderMessage {
|
|
||||||
pub fn new(command: ProviderCommand) -> Self {
|
|
||||||
Self {
|
|
||||||
span: Span::current(),
|
|
||||||
command,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ProviderCommand {
|
|
||||||
GetLibraryNode {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
|
||||||
},
|
|
||||||
GetTrackUrls {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
|
||||||
},
|
|
||||||
/// Resolves a path into playable tracks: a track path yields that single
|
|
||||||
/// track, a node path yields all tracks reachable below it. Streamed:
|
|
||||||
/// zero or more in-order chunks arrive on `chunk_tx`, the sender is
|
|
||||||
/// dropped when resolution finishes, and dropping the receiver cancels
|
|
||||||
/// it (see `ProviderClient::resolve_tracks_into`). The orchestrator
|
|
||||||
/// handles this command on a spawned task so its loop stays free for
|
|
||||||
/// other commands (notably `GetTrackUrls` for the first chunk's track).
|
|
||||||
ResolveTracks {
|
|
||||||
path: String,
|
|
||||||
chunk_tx: flume::Sender<Vec<Track>>,
|
|
||||||
},
|
|
||||||
/// Creates a child under a creatable node (see
|
|
||||||
/// `ProviderClient::create_lib_node`); replies with the created node.
|
|
||||||
CreateLibraryNode {
|
|
||||||
parent_path: String,
|
|
||||||
title: String,
|
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
|
||||||
},
|
|
||||||
/// Renames an editable node (see `ProviderClient::rename_lib_node`);
|
|
||||||
/// replies with the renamed node at its new path.
|
|
||||||
RenameLibraryNode {
|
|
||||||
path: String,
|
|
||||||
new_title: String,
|
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
|
||||||
},
|
|
||||||
/// Deletes a deletable node (see `ProviderClient::delete_lib_node`);
|
|
||||||
/// replies with the refreshed parent node.
|
|
||||||
DeleteLibraryNode {
|
|
||||||
path: String,
|
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
|
||||||
},
|
|
||||||
/// Captures the queueable subtree at `path` as the bookmark `name`
|
|
||||||
/// (see `architecture/bookmarks.md` D1–D3) — or, with `download`, as
|
|
||||||
/// the download capture `name` under `/captures`, fetching every
|
|
||||||
/// track's audio (see `architecture/captures.md`). Handled on a
|
|
||||||
/// spawned task — a large walk or download must not block the
|
|
||||||
/// orchestrator loop.
|
|
||||||
CaptureLibraryNode {
|
|
||||||
path: String,
|
|
||||||
name: String,
|
|
||||||
download: bool,
|
|
||||||
result_tx: flume::Sender<Result<(), crabidy_server::capture::CaptureError>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderCommand {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::GetLibraryNode { .. } => "get_library_node",
|
|
||||||
Self::GetTrackUrls { .. } => "get_track_urls",
|
|
||||||
Self::ResolveTracks { .. } => "resolve_tracks",
|
|
||||||
Self::CreateLibraryNode { .. } => "create_library_node",
|
|
||||||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
|
||||||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
|
||||||
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PlaybackMessage {
|
|
||||||
pub span: Span,
|
|
||||||
pub command: PlaybackCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackMessage {
|
|
||||||
pub fn new(command: PlaybackCommand) -> Self {
|
|
||||||
Self {
|
|
||||||
span: Span::current(),
|
|
||||||
command,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum PlaybackCommand {
|
|
||||||
Init {
|
|
||||||
result_tx: flume::Sender<InitResponse>,
|
|
||||||
},
|
|
||||||
Replace {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Queue {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Append {
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
Remove {
|
|
||||||
positions: Vec<u32>,
|
|
||||||
},
|
|
||||||
Insert {
|
|
||||||
position: u32,
|
|
||||||
paths: Vec<String>,
|
|
||||||
},
|
|
||||||
/// Internal: a resolved chunk of tracks for the pending queue operation
|
|
||||||
/// `op_id`, sent by that operation's forwarder task. Chunks for an
|
|
||||||
/// unknown (finished or cancelled) op are dropped silently.
|
|
||||||
ApplyResolvedChunk {
|
|
||||||
op_id: u64,
|
|
||||||
tracks: Vec<Track>,
|
|
||||||
},
|
|
||||||
/// Internal: the forwarder task for `op_id` has seen the provider drop
|
|
||||||
/// its chunk sender — the operation is complete and the `resolving`
|
|
||||||
/// flag clears once no pending operations remain.
|
|
||||||
ResolveFinished {
|
|
||||||
op_id: u64,
|
|
||||||
},
|
|
||||||
Clear {
|
|
||||||
exclude_current: bool,
|
|
||||||
},
|
|
||||||
SetCurrent {
|
|
||||||
position: u32,
|
|
||||||
},
|
|
||||||
/// Saves the current queue under a name (see
|
|
||||||
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
|
||||||
/// snapshot is consistent; the disk write happens on a spawned task and
|
|
||||||
/// reports through `result_tx`.
|
|
||||||
SaveQueue {
|
|
||||||
name: String,
|
|
||||||
result_tx: flume::Sender<Result<(), crabidy_server::queue_store::SaveQueueError>>,
|
|
||||||
},
|
|
||||||
ToggleShuffle,
|
|
||||||
ToggleRepeat,
|
|
||||||
TogglePlay,
|
|
||||||
Stop,
|
|
||||||
ChangeVolume {
|
|
||||||
delta: f32,
|
|
||||||
},
|
|
||||||
ToggleMute,
|
|
||||||
Next,
|
|
||||||
Prev,
|
|
||||||
RestartTrack,
|
|
||||||
StateChanged {
|
|
||||||
state: PlayState,
|
|
||||||
},
|
|
||||||
VolumeChanged {
|
|
||||||
volume: f32,
|
|
||||||
},
|
|
||||||
MuteChanged {
|
|
||||||
muted: bool,
|
|
||||||
},
|
|
||||||
PositionChanged {
|
|
||||||
duration: u32,
|
|
||||||
position: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackCommand {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Init { .. } => "init",
|
|
||||||
Self::Replace { .. } => "replace",
|
|
||||||
Self::Queue { .. } => "queue",
|
|
||||||
Self::Append { .. } => "append",
|
|
||||||
Self::Remove { .. } => "remove",
|
|
||||||
Self::Insert { .. } => "insert",
|
|
||||||
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
|
||||||
Self::ResolveFinished { .. } => "resolve_finished",
|
|
||||||
Self::Clear { .. } => "clear",
|
|
||||||
Self::SetCurrent { .. } => "set_current",
|
|
||||||
Self::SaveQueue { .. } => "save_queue",
|
|
||||||
Self::ToggleShuffle => "toggle_shuffle",
|
|
||||||
Self::ToggleRepeat => "toggle_repeat",
|
|
||||||
Self::TogglePlay => "toggle_play",
|
|
||||||
Self::Stop => "stop",
|
|
||||||
Self::ChangeVolume { .. } => "change_volume",
|
|
||||||
Self::ToggleMute => "toggle_mute",
|
|
||||||
Self::Next => "next",
|
|
||||||
Self::Prev => "prev",
|
|
||||||
Self::RestartTrack => "restart_track",
|
|
||||||
Self::StateChanged { .. } => "state_changed",
|
|
||||||
Self::VolumeChanged { .. } => "volume_changed",
|
|
||||||
Self::MuteChanged { .. } => "mute_changed",
|
|
||||||
Self::PositionChanged { .. } => "position_changed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
||||||
|
use crate::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use audio_player::Player;
|
use audio_player::Player;
|
||||||
use crabidy_core::proto::crabidy::QueueModifiers;
|
use crabidy_core::proto::crabidy::QueueModifiers;
|
||||||
|
|
@ -6,8 +8,6 @@ use crabidy_core::proto::crabidy::{
|
||||||
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use crabidy_server::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
|
||||||
use crabidy_server::{PendingResolve, QueueManager, ResolveKind};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
|
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
||||||
|
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
||||||
|
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
||||||
use crate::{ProviderCommand, ProviderMessage};
|
use crate::{ProviderCommand, ProviderMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crabidy_core::{
|
use crabidy_core::{
|
||||||
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
ProviderClient, ProviderError,
|
ProviderClient, ProviderError,
|
||||||
};
|
};
|
||||||
use crabidy_server::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
|
||||||
use crabidy_server::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
|
||||||
use crabidy_server::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
|
||||||
use std::{fs, path::PathBuf, sync::Arc};
|
use std::{fs, path::PathBuf, sync::Arc};
|
||||||
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
||||||
|
|
||||||
|
|
@ -200,12 +200,12 @@ impl ProviderOrchestrator {
|
||||||
let result = if download {
|
let result = if download {
|
||||||
match &this.capture_store {
|
match &this.capture_store {
|
||||||
Some(store) => store.capture(&*this, &path, &name).await,
|
Some(store) => store.capture(&*this, &path, &name).await,
|
||||||
None => Err(crabidy_server::capture::CaptureError::Disabled),
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match &this.bookmark_store {
|
match &this.bookmark_store {
|
||||||
Some(store) => store.capture(&*this, &path, &name).await,
|
Some(store) => store.capture(&*this, &path, &name).await,
|
||||||
None => Err(crabidy_server::capture::CaptureError::Disabled),
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(err) = &result {
|
if let Err(err) = &result {
|
||||||
|
|
@ -271,7 +271,7 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
// into; a folder that does not exist yet lists as empty-on-arrival
|
// into; a folder that does not exist yet lists as empty-on-arrival
|
||||||
// (created by QueueStore::open in main). Saved queues are renamable
|
// (created by QueueStore::open in main). Saved queues are renamable
|
||||||
// and deletable; the auto-persisted `current` stays untouchable.
|
// and deletable; the auto-persisted `current` stays untouchable.
|
||||||
let queues_client = match crabidy_server::queue_store::queues_dir() {
|
let queues_client = match crate::queue_store::queues_dir() {
|
||||||
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
||||||
Ok(client) => Some(Arc::new(
|
Ok(client) => Some(Arc::new(
|
||||||
client
|
client
|
||||||
|
|
@ -291,7 +291,7 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
// Bookmarks: the orchestrator owns the store (it is the capture
|
// Bookmarks: the orchestrator owns the store (it is the capture
|
||||||
// writer) and mounts the same folder read-only. Non-fatal like the
|
// writer) and mounts the same folder read-only. Non-fatal like the
|
||||||
// other local providers.
|
// other local providers.
|
||||||
let bookmark_store = match crabidy_server::bookmark_store::bookmarks_dir() {
|
let bookmark_store = match crate::bookmark_store::bookmarks_dir() {
|
||||||
Some(dir) => match BookmarkStore::open(dir).await {
|
Some(dir) => match BookmarkStore::open(dir).await {
|
||||||
Ok(store) => Some(Arc::new(store)),
|
Ok(store) => Some(Arc::new(store)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -319,7 +319,7 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
});
|
});
|
||||||
// Captures: like bookmarks, but the store downloads every track's
|
// Captures: like bookmarks, but the store downloads every track's
|
||||||
// audio next to its toml (architecture/captures.md D1). Non-fatal.
|
// audio next to its toml (architecture/captures.md D1). Non-fatal.
|
||||||
let capture_store = match crabidy_server::capture_store::captures_dir() {
|
let capture_store = match crate::capture_store::captures_dir() {
|
||||||
Some(dir) => match CaptureStore::open(dir).await {
|
Some(dir) => match CaptureStore::open(dir).await {
|
||||||
Ok(store) => Some(Arc::new(store)),
|
Ok(store) => Some(Arc::new(store)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use crate::bookmark_store::CaptureError;
|
||||||
|
use crate::queue_store::SaveQueueError;
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
|
|
@ -15,8 +17,6 @@ use crabidy_core::proto::crabidy::{
|
||||||
ToggleShuffleRequest, ToggleShuffleResponse,
|
ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use crabidy_server::bookmark_store::CaptureError;
|
|
||||||
use crabidy_server::queue_store::SaveQueueError;
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Plan: cbd-bundle
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `cbd/src/main.rs`
|
||||||
|
and/or gates in `quality/cbd-bundle.md`).
|
||||||
|
|
||||||
|
- [x] **T1 — Server library extraction.** Move `playback`/`provider`/
|
||||||
|
`rpc` and the command/message enums into the `crabidy-server` lib;
|
||||||
|
add `serve(addr)` + `LISTEN_ADDR`; thin `main.rs`. Verifies: the
|
||||||
|
moved server suite passes unchanged; gates "Refactor".
|
||||||
|
- [x] **T2 — TUI library extraction.** `cbd-tui/src/lib.rs` with
|
||||||
|
`run(config)` (orchestrate + run_ui + channels); thin `main.rs` keeps
|
||||||
|
file tracing + config. Verifies: the moved TUI suite passes
|
||||||
|
unchanged.
|
||||||
|
- [x] **T3 — cbd crate.** Workspace member; main = shared file tracing,
|
||||||
|
`cbd-tui.toml` config, spawn `serve(LISTEN_ADDR)`, `wait_for_server`
|
||||||
|
(scheme-stripped TCP poll, adopt-on-occupied-port, dead-server
|
||||||
|
error, bounded attempts), then `cbd_tui::run`. Verifies:
|
||||||
|
`readiness_polls_until_the_socket_accepts`,
|
||||||
|
`readiness_gives_up_and_reports_a_dead_server`; gates "Bundled
|
||||||
|
behavior".
|
||||||
|
- [x] **T4 — Full verification.** Workspace suite green; clippy/fmt/
|
||||||
|
taplo/markdownlint clean.
|
||||||
|
- [x] **T5 — Live smoke test.** Boot the extracted `serve()` on a free
|
||||||
|
port through `wait_for_server` (temporary ignored probe, removed
|
||||||
|
after passing): the real stack — tidal login, providers, playback,
|
||||||
|
queue restore — accepted a TCP connection in ~1 s.
|
||||||
|
- [x] **T6 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/cbd-bundle.md`.
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## cbd-bundle (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/cbd-bundle.md`: a new **`cbd`** binary bundles server
|
||||||
|
and TUI. Both former binaries became libraries with thin mains —
|
||||||
|
`crabidy_server::serve(addr)` is the extracted server startup
|
||||||
|
(orchestrator, queue store, playback, player forwarder, tonic), and
|
||||||
|
`cbd_tui::run(config)` the extracted client loops; the standalone
|
||||||
|
binaries behave exactly as before. `cbd` sets up one file-based tracing
|
||||||
|
subscriber for both halves (the terminal belongs to the TUI), spawns
|
||||||
|
`serve` on the fixed listen address, polls a TCP connect against the
|
||||||
|
TUI's configured server address until ready (bounded, generous — first
|
||||||
|
runs may sit in a provider login), then runs the TUI. An
|
||||||
|
already-running standalone server just gets adopted (the in-process
|
||||||
|
bind fails on the occupied port and is deliberately ignored once the
|
||||||
|
socket is reachable); a server that dies before readiness surfaces its
|
||||||
|
real error. Quitting the TUI ends the process and the in-process
|
||||||
|
server — the continuously persisted current queue makes that safe.
|
||||||
|
|
||||||
|
Deviations: none of substance — the refactor moved code verbatim
|
||||||
|
(`crabidy_server::` → `crate::` path rewrites aside). The live probe
|
||||||
|
booted the extracted stack on a free port through the same readiness
|
||||||
|
poll `cbd` uses: real tidal login, all providers, playback, queue
|
||||||
|
restore, TCP accept in ~1 s (probe removed after passing). 154
|
||||||
|
workspace tests green (2 new in `cbd`); every gate in
|
||||||
|
`quality/cbd-bundle.md` checked.
|
||||||
|
|
||||||
## captures follow-up: W on queues and bookmarks (2026-07-21)
|
## captures follow-up: W on queues and bookmarks (2026-07-21)
|
||||||
|
|
||||||
Small fix on top of the captures feature: `/queues` and `/bookmarks`
|
Small fix on top of the captures feature: `/queues` and `/bookmarks`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Quality gates: cbd-bundle
|
||||||
|
|
||||||
|
Criteria beyond the automatic tests (`cbd/src/main.rs` readiness tests
|
||||||
|
plus the moved suites). Each gate is pass/fail by reading the code.
|
||||||
|
|
||||||
|
## Refactor (behavior-preserving)
|
||||||
|
|
||||||
|
- [x] The server's modules (`playback`, `provider`, `rpc`) and command/
|
||||||
|
message types moved to the library **unchanged**; `serve(addr)` is the
|
||||||
|
old `main` body verbatim (minus tracing); both thin mains only set up
|
||||||
|
tracing and delegate.
|
||||||
|
- [x] Every pre-existing test moved along and passes unchanged; no
|
||||||
|
test was rewritten to accommodate the refactor.
|
||||||
|
- [x] `cbd-tui`'s library exposes `run(config)`; tracing setup stays in
|
||||||
|
the binaries (where logs go is a hosting decision).
|
||||||
|
|
||||||
|
## Bundled behavior
|
||||||
|
|
||||||
|
- [x] `cbd` uses the same configs as the pair (`cbd-tui.toml` and the
|
||||||
|
server-side configs) — nothing bundled-specific to configure.
|
||||||
|
- [x] Both halves log to one file; the terminal is never written to
|
||||||
|
outside the TUI.
|
||||||
|
- [x] An already-running standalone server is adopted (occupied port +
|
||||||
|
reachable socket → connect), never treated as an error.
|
||||||
|
- [x] A server that dies before readiness surfaces its actual error;
|
||||||
|
a slow one gets a bounded, generous wait (first-run login flows).
|
||||||
|
- [x] The readiness poll strips the config URL's scheme and never
|
||||||
|
parses beyond host:port.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean; all
|
||||||
|
tests green.
|
||||||
|
- [x] `architecture/cbd-bundle.md` reconciled where the implementation
|
||||||
|
diverged.
|
||||||
Loading…
Reference in New Issue