feat(cli)!: replace the one-shot commands with a scriptable surface

The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.

The commands are now built for scripts and agents:

  search  download  get  browse  room list|say|listen  message send|read

stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.

Defects found and fixed along the way, each covered by a test:

- a download with an unknown size was finalized against zero bytes and saved
  as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
  peers shadowed itself and two files sharing a basename overwrote each
  other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
  wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
  server went quiet

Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent c2dd227cee
commit be6581ee6c
17 changed files with 3756 additions and 1583 deletions
+6 -334
View File
@@ -1,305 +1,20 @@
use crate::models::DownloadEntry;
use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style,
download_status_glyph, error_style, format_bytes_progress,
format_progress_bar, format_shortcuts_styled, format_speed, header_style,
highlight_style, info_style, pane_block, plain_title, primary_style,
success_style, warning_style,
BYTES_PER_MB, accent_style, dimmed_style, error_style,
format_bytes_progress, format_progress_bar, info_style, pane_block,
plain_title, primary_style, success_style, warning_style,
};
use color_eyre::Result;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
Frame,
layout::{Alignment, Constraint, Layout},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
TableState,
},
};
use soulseek_rs::{Client, DownloadStatus};
use std::{
sync::{Arc, mpsc, mpsc::Receiver},
thread,
time::Duration,
widgets::Paragraph,
};
use soulseek_rs::DownloadStatus;
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct MultiDownloadProgress {
downloads: Vec<DownloadEntry>,
receiver_channel:
Receiver<(soulseek_rs::types::Download, Receiver<DownloadStatus>)>,
list_state: TableState,
max_concurrent: usize,
active_count: usize,
should_exit: bool,
queuing_status: String,
}
impl MultiDownloadProgress {
pub fn new(
receiver_channel: Receiver<(
soulseek_rs::types::Download,
Receiver<DownloadStatus>,
)>,
max_concurrent: usize,
) -> Self {
let mut list_state = TableState::default();
list_state.select(Some(0));
Self {
downloads: Vec::new(),
receiver_channel,
list_state,
max_concurrent,
active_count: 0,
should_exit: false,
queuing_status: String::from("Queuing downloads..."),
}
}
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
self.start_next_batch();
loop {
terminal.draw(|frame| self.render(frame))?;
while let Ok((download, receiver)) =
self.receiver_channel.try_recv()
{
self.downloads.push(DownloadEntry {
download,
receiver: Some(receiver),
});
self.queuing_status =
format!("{} downloads queued", self.downloads.len());
}
// Poll all receivers for status updates
let mut need_start_next = false;
for download_entry in &mut self.downloads {
if let Some(receiver) = &download_entry.receiver
&& let Ok(status) = receiver.try_recv()
{
let was_active = !download_entry.download.is_finished();
download_entry.download.status = status;
let is_finished = download_entry.download.is_finished();
// If download just finished, decrement active count
if was_active && is_finished {
self.active_count = self.active_count.saturating_sub(1);
need_start_next = true;
}
}
}
if need_start_next {
self.start_next_batch();
}
if poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
self.handle_key(key);
}
// Exit when user cancels or all downloads finished
if self.should_exit || self.all_finished() {
break;
}
}
Ok(())
}
fn start_next_batch(&mut self) {
for download_entry in &self.downloads {
if self.active_count >= self.max_concurrent {
break;
}
// Only start if download is queued AND receiver is available
if matches!(download_entry.download.status, DownloadStatus::Queued)
&& download_entry.receiver.is_some()
{
self.active_count += 1;
// Download will start automatically since receiver exists
}
}
}
fn all_finished(&self) -> bool {
self.downloads.iter().all(|d| d.download.is_finished())
}
fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
KeyCode::End | KeyCode::Char('G') => self.select_last(),
KeyCode::Esc | KeyCode::Char('q') => {
self.should_exit = true;
}
_ => {}
}
}
const fn select_previous(&mut self) {
let i = match self.list_state.selected() {
Some(i) if i > 0 => i - 1,
_ => self.downloads.len().saturating_sub(1),
};
self.list_state.select(Some(i));
}
const fn select_next(&mut self) {
let i = match self.list_state.selected() {
Some(i) if i < self.downloads.len().saturating_sub(1) => i + 1,
_ => 0,
};
self.list_state.select(Some(i));
}
const fn select_first(&mut self) {
self.list_state.select(Some(0));
}
const fn select_last(&mut self) {
if !self.downloads.is_empty() {
self.list_state.select(Some(self.downloads.len() - 1));
}
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(area);
// Top: Overall stats
self.render_stats(frame, chunks[0]);
// Middle: List of downloads
self.render_downloads_list(frame, chunks[1]);
// Bottom: Controls
Self::render_controls(frame, chunks[2]);
}
fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
render_download_stats(frame, area, &self.downloads, self.active_count);
}
fn render_downloads_list(
&mut self,
frame: &mut Frame,
area: ratatui::layout::Rect,
) {
let header = Row::new(vec![
Cell::from("St"),
Cell::from("Filename"),
Cell::from("Username"),
Cell::from("Size"),
Cell::from("Progress"),
Cell::from("Speed"),
])
.style(header_style())
.height(1);
let rows: Vec<Row> = self
.downloads
.iter()
.map(|download_entry| {
let download = &download_entry.download;
let (status_icon, status_style) =
download_status_glyph(&download.status);
let progress = if download.size > 0 {
download.bytes_downloaded() as f64 / download.size as f64
} else {
0.0
};
let percent = (progress * 100.0) as u8;
let bar_width = 20;
let progress_bar =
format_progress_bar(progress, bar_width, percent);
let size_str = format_bytes_progress(
download.bytes_downloaded(),
download.size,
);
let speed_str = match download.status {
DownloadStatus::InProgress { .. } => {
format_speed(download.speed_bytes_per_sec())
}
_ => "-".to_string(),
};
let cells = vec![
Cell::from(status_icon).style(status_style),
Cell::from(download.filename.clone()).style(body_style()),
Cell::from(download.username.clone()).style(info_style()),
Cell::from(size_str).style(warning_style()),
Cell::from(progress_bar),
Cell::from(speed_str).style(dimmed_style()),
];
Row::new(cells).height(1)
})
.collect();
let widths = [
Constraint::Length(3),
Constraint::Min(30),
Constraint::Length(15),
Constraint::Length(20),
Constraint::Length(28),
Constraint::Length(12),
];
let table = Table::new(rows, widths)
.header(header)
.block(pane_block(true).title(plain_title("Downloads", true)))
.column_spacing(1)
.row_highlight_style(highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(
table,
area,
frame.buffer_mut(),
&mut self.list_state,
);
}
fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) {
let controls_line = format_shortcuts_styled(&[
("↑↓/jk", "scroll"),
("Home/End", "jump"),
("Esc/q", "cancel all"),
]);
let controls_widget = Paragraph::new(controls_line)
.block(pane_block(false).title(plain_title("Controls", false)));
frame.render_widget(controls_widget, area);
}
}
/// Bytes transferred, bytes expected and combined speed across the downloads
/// that are actually running — or `None` when none are, so the caller can hide
/// the progress readout entirely.
@@ -432,49 +147,6 @@ pub fn render_download_stats(
frame.render_widget(progress_paragraph, chunks[1]);
}
pub fn show_multi_download_progress(
terminal: DefaultTerminal,
client: Arc<Client>,
selected_files: Vec<(String, String, u64)>,
download_dir: String,
max_concurrent: usize,
) -> Result<()> {
soulseek_rs::utils::logger::enable_buffering();
let (tx, rx) = mpsc::channel();
// Spawn background thread to initialize downloads
let init_client = client;
thread::spawn(move || {
for (filename, username, size) in selected_files {
// Initiate download
match init_client.download(
filename.clone(),
username.clone(),
size,
download_dir.clone(),
) {
Ok((download, receiver)) => {
let _ = tx.send((download, receiver));
}
Err(e) => {
soulseek_rs::warn!(
"Failed to start download for {filename}: {e}"
);
}
}
}
});
let mut progress = MultiDownloadProgress::new(rx, max_concurrent);
let result = progress.run(terminal);
ratatui::restore();
soulseek_rs::utils::logger::flush_buffered_logs();
result
}
#[cfg(test)]
mod tests {
use super::{DownloadEntry, DownloadStatus, active_progress};