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
+495 -96
View File
@@ -1,132 +1,531 @@
use clap::{Parser, Subcommand};
//! Command-line surface.
//!
//! Two modes share one binary: no subcommand launches the interactive TUI,
//! any subcommand runs a one-shot operation designed for scripts and agents.
//! The one-shot half follows three rules — stdout carries data only, stderr
//! carries progress, and the exit code carries the verdict (see
//! [`crate::output::Exit`]).
use clap::builder::FalseyValueParser;
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser, Debug)]
/// Upper bound for every wait expressed in seconds. A one-shot command that
/// runs longer than a day is a mistyped flag, and rejecting it here keeps the
/// deadline arithmetic in the command layer from overflowing.
const MAX_WAIT_SECS: u64 = 24 * 60 * 60;
const EXAMPLES: &str = "\
Examples:
soulseek-rs launch the interactive TUI
soulseek-rs search 'aphex twin xtal' print matching files
soulseek-rs get 'aphex twin xtal' search, pick the best, download
soulseek-rs search 'rjd2' --json | jq -c 'select(.bitrate >= 320)' \\
| soulseek-rs download --stdin filter, then fetch
soulseek-rs browse someuser --json list a peer's shares
soulseek-rs room listen lobby --follow stream room chat
Exit codes:
0 success 2 usage/config 3 connect or login 4 no results
5 timed out 6 transfer failed 1 unexpected error";
#[derive(Parser, Debug, Default)]
#[command(
name = "soulseek-rs",
author,
version,
about = "Soulseek client in Rust 🦀",
long_about = None
long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \
interactive TUI, or use a subcommand for a scriptable \
one-shot operation: stdout is data, stderr is progress, and \
the exit code says what happened.",
after_help = EXAMPLES
)]
// A flag is a bool; a command line with several switches is not a struct that
// wants splitting up.
#[allow(clippy::struct_excessive_bools)]
pub struct Cli {
#[arg(short, long, env = "SOULSEEK_USERNAME")]
pub username: Option<String>,
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
pub password: Option<String>,
/// Server address as host:port (default: server.slsknet.org:2416)
#[arg(short, long, env = "SOULSEEK_SERVER")]
pub server: Option<String>,
#[arg(long, env = "DISABLE_LISTENER")]
pub disable_listener: bool,
/// Port to accept peer connections on (default: 2234)
#[arg(short, long, env = "LISTENER_PORT")]
pub listener_port: Option<u16>,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(
long,
env = "LOG_FILE",
help = "Write logs to file instead of stderr"
)]
pub log_file: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
/// Directory downloads are saved to (default: your Downloads/Soulseek
/// folder, which is also shared unless shared_dir says otherwise)
#[arg(short, long, env = "SOULSEEK_DOWNLOAD_DIR")]
pub download_dir: Option<String>,
/// Soulseek username
#[arg(short, long, global = true, env = "SOULSEEK_USERNAME")]
pub username: Option<String>,
/// Password (visible in `ps`; prefer --password-stdin or the OS keychain)
#[arg(
short,
long,
global = true,
env = "SOULSEEK_PASSWORD",
hide_env_values = true
)]
pub password: Option<String>,
/// Read the password from the first line of stdin (takes precedence
/// over --password)
#[arg(long, global = true)]
pub password_stdin: bool,
/// Shell command whose stdout is the password, used when nothing else
/// supplies one
#[arg(
long,
env = "SOULSEEK_SHARED_DIR",
help = "Directory whose files are shared with (uploaded to) other peers"
global = true,
env = "SOULSEEK_PASSWORD_CMD",
hide_env_values = true
)]
pub shared_dir: Option<String>,
pub password_cmd: Option<String>,
/// Maximum simultaneous downloads (default: 5)
#[arg(short = 'c', long, env = "MAX_CONCURRENT_DOWNLOADS")]
/// Server as host:port
#[arg(long, global = true, env = "SOULSEEK_SERVER")]
pub server: Option<String>,
/// Do not accept incoming peer connections
#[arg(
long,
global = true,
env = "SOULSEEK_NO_LISTENER",
action = ArgAction::SetTrue,
value_parser = FalseyValueParser::new()
)]
pub no_listener: bool,
/// Accept incoming peer connections, overriding the config file
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub listener: bool,
/// Port to accept peer connections on
#[arg(long, global = true, env = "SOULSEEK_LISTENER_PORT")]
pub listener_port: Option<u16>,
/// Directory downloads are saved to
#[arg(short, long, global = true, env = "SOULSEEK_DOWNLOAD_DIR")]
pub download_dir: Option<String>,
/// Directory shared with other peers; repeat for several
#[arg(long, global = true, env = "SOULSEEK_SHARED_DIR", action = ArgAction::Append)]
pub shared_dir: Vec<String>,
/// Maximum simultaneous downloads
#[arg(
short = 'c',
long,
global = true,
env = "SOULSEEK_MAX_CONCURRENT_DOWNLOADS"
)]
pub max_concurrent_downloads: Option<usize>,
/// Seconds a search stays active (default: 10)
#[arg(long)]
/// Seconds a search collects results before reporting
#[arg(
long,
global = true,
env = "SOULSEEK_SEARCH_TIMEOUT",
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub search_timeout: Option<u64>,
/// Config file to read instead of the default location
#[arg(long, global = true, env = "SOULSEEK_CONFIG", value_name = "FILE")]
pub config: Option<PathBuf>,
/// Ignore the config file entirely
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_config: bool,
/// Emit newline-delimited JSON instead of tab-separated text
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub json: bool,
/// Suppress progress and status output on stderr
#[arg(short, long, global = true, action = ArgAction::SetTrue)]
pub quiet: bool,
/// Increase log verbosity (repeat up to -vvvv)
#[arg(short, long, global = true, action = ArgAction::Count)]
pub verbose: u8,
/// Write logs to a file instead of stderr
#[arg(long, global = true, env = "SOULSEEK_LOG_FILE")]
pub log_file: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Search {
query: String,
/// Search the network and print matching files
Search(SearchArgs),
#[arg(short, long, default_value = "10")]
timeout: u64,
/// Download files by user and remote path
Download(DownloadArgs),
#[arg(short, long, default_value = "~/Downloads")]
download_dir: String,
/// Search, pick automatically, and download in one step
Get(GetArgs),
#[arg(
short = 'c',
long,
env = "MAX_CONCURRENT_DOWNLOADS",
default_value = "5"
)]
max_concurrent_downloads: usize,
},
/// List the files a user shares
Browse(BrowseArgs),
/// Send a private message to another user
Message {
/// Username of the recipient
username: String,
/// Chat rooms
#[command(subcommand)]
Room(RoomCommand),
/// Message text to send
message: String,
},
/// Private messages
#[command(subcommand)]
Message(MessageCommand),
/// Browse another user's shared files
Browse {
/// Username whose shares to list
username: String,
},
/// List the public chat rooms and their user counts
Rooms,
/// Join a chat room: print messages, or send one and exit
Chat {
/// Name of the room to join
room: String,
/// Optional message to say in the room (omit to just listen)
message: Option<String>,
/// Seconds to stay and print incoming messages when only listening
#[arg(short, long, default_value = "30")]
listen_secs: u64,
},
/// Test whether your router lets us auto-open the listen port (UPnP/NAT-PMP)
/// Check whether the router opens the listen port (UPnP/NAT-PMP)
Portmap,
}
pub fn parse_server_address(server: &str) -> color_eyre::Result<(String, u16)> {
let parts: Vec<&str> = server.split(':').collect();
if parts.len() != 2 {
return Err(color_eyre::eyre::eyre!(
"Invalid server format. Expected 'host:port', got '{}'",
server
/// How to order search results.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SortKey {
/// Free slot first, then bitrate, peer speed, and size
Best,
/// Largest file first
Size,
/// Highest bitrate first
Bitrate,
/// Fastest peer first
Speed,
}
/// Which of the ranked results `get` should download.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum Pick {
/// The single best-ranked result
Best,
/// The first result that arrives, without waiting out the search
First,
/// Every result, subject to --limit
All,
}
#[derive(Args, Debug)]
pub struct SearchArgs {
/// What to search for
pub query: String,
/// Drop results below this bitrate in kbps
#[arg(long, value_name = "KBPS")]
pub min_bitrate: Option<u32>,
/// Keep only peers advertising a free upload slot
#[arg(long, action = ArgAction::SetTrue)]
pub free_slots: bool,
/// Print at most this many results (0 means all)
#[arg(short = 'n', long, default_value_t = 0)]
pub limit: usize,
/// Result order
#[arg(long, value_enum, default_value_t = SortKey::Best)]
pub sort: SortKey,
}
#[derive(Args, Debug)]
pub struct DownloadArgs {
/// Peer holding the file
#[arg(required_unless_present = "stdin")]
pub user: Option<String>,
/// Remote path exactly as printed by `search` or `browse`
#[arg(required_unless_present = "stdin")]
pub path: Option<String>,
/// File size in bytes, as reported by `search` or `browse`
#[arg(long)]
pub size: Option<u64>,
/// Read records from stdin: `search --json` output, or TAB-separated
/// `search` text lines
#[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["user", "path", "size"])]
pub stdin: bool,
/// Seconds to wait for each transfer before giving up
#[arg(
short,
long,
default_value_t = 300,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub timeout: u64,
}
#[derive(Args, Debug)]
pub struct GetArgs {
/// What to search for
pub query: String,
/// Which results to download
#[arg(long, value_enum, default_value_t = Pick::Best)]
pub pick: Pick,
/// With `--pick all`, download at most this many (0 means all)
#[arg(short = 'n', long, default_value_t = 0)]
pub limit: usize,
/// Drop results below this bitrate in kbps
#[arg(long, value_name = "KBPS")]
pub min_bitrate: Option<u32>,
/// Keep only peers advertising a free upload slot
#[arg(long, action = ArgAction::SetTrue)]
pub free_slots: bool,
/// Seconds to wait for each transfer before giving up
#[arg(
short,
long,
default_value_t = 300,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub timeout: u64,
}
#[derive(Args, Debug)]
pub struct BrowseArgs {
/// User whose shares to list
pub user: String,
/// Seconds to wait for the listing
#[arg(
short,
long,
default_value_t = 20,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub timeout: u64,
}
#[derive(Subcommand, Debug)]
pub enum RoomCommand {
/// List public rooms and their user counts
List {
/// Seconds to wait for the list
#[arg(
short,
long,
default_value_t = 5,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
timeout: u64,
},
/// Post a message to a room
Say {
/// Room to post in
room: String,
/// Message text
message: String,
},
/// Stream messages and joins/leaves from a room
Listen {
/// Room to listen to
room: String,
/// Seconds to listen before exiting
#[arg(
long,
default_value_t = 30,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
duration: u64,
/// Listen until interrupted, ignoring --duration
#[arg(long, action = ArgAction::SetTrue)]
follow: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum MessageCommand {
/// Send a private message
Send {
/// Recipient username
user: String,
/// Message text
message: String,
},
/// Stream incoming private messages
Read {
/// Seconds to listen before exiting
#[arg(
long,
default_value_t = 30,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
duration: u64,
/// Listen until interrupted, ignoring --duration
#[arg(long, action = ArgAction::SetTrue)]
follow: bool,
},
}
/// Split `host:port`, the only shape the server setting accepts.
pub fn parse_server_address(server: &str) -> Result<(String, u16), String> {
let (host, port) = server.rsplit_once(':').ok_or_else(|| {
format!("Invalid server '{server}': expected host:port")
})?;
if host.is_empty() {
return Err(format!("Invalid server '{server}': missing host"));
}
let port = port
.parse::<u16>()
.map_err(|_| format!("Invalid server '{server}': bad port '{port}'"))?;
Ok((host.to_string(), port))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("should parse")
}
#[test]
fn clap_definitions_are_valid() {
Cli::command().debug_assert();
}
#[test]
fn no_subcommand_means_tui() {
assert!(parse(&["soulseek-rs"]).command.is_none());
}
#[test]
fn global_flags_parse_after_the_subcommand() {
let cli = parse(&[
"soulseek-rs",
"search",
"query",
"--username",
"alice",
"--json",
"--search-timeout",
"30",
]);
assert_eq!(cli.username.as_deref(), Some("alice"));
assert!(cli.json);
assert_eq!(cli.search_timeout, Some(30));
}
#[test]
fn global_flags_parse_before_the_subcommand() {
let cli =
parse(&["soulseek-rs", "--username", "alice", "browse", "bob"]);
assert_eq!(cli.username.as_deref(), Some("alice"));
assert!(matches!(cli.command, Some(Commands::Browse(_))));
}
#[test]
fn shared_dir_repeats_into_a_list() {
let cli =
parse(&["soulseek-rs", "--shared-dir", "/a", "--shared-dir", "/b"]);
assert_eq!(cli.shared_dir, vec!["/a".to_string(), "/b".to_string()]);
}
#[test]
fn download_requires_user_and_path_without_stdin() {
assert!(Cli::try_parse_from(["soulseek-rs", "download"]).is_err());
assert!(
Cli::try_parse_from(["soulseek-rs", "download", "bob"]).is_err()
);
assert!(
Cli::try_parse_from(["soulseek-rs", "download", "bob", "a\\b.mp3"])
.is_ok()
);
}
#[test]
fn download_stdin_needs_no_positionals() {
let cli = parse(&["soulseek-rs", "download", "--stdin"]);
let Some(Commands::Download(args)) = cli.command else {
panic!("expected download");
};
assert!(args.stdin);
assert!(args.user.is_none());
}
#[test]
fn download_stdin_conflicts_with_positionals() {
assert!(
Cli::try_parse_from([
"soulseek-rs",
"download",
"--stdin",
"bob",
"a.mp3"
])
.is_err()
);
}
#[test]
fn room_and_message_groups_parse() {
assert!(matches!(
parse(&["soulseek-rs", "room", "list"]).command,
Some(Commands::Room(RoomCommand::List { .. }))
));
assert!(matches!(
parse(&["soulseek-rs", "room", "say", "lobby", "hi"]).command,
Some(Commands::Room(RoomCommand::Say { .. }))
));
assert!(matches!(
parse(&["soulseek-rs", "message", "read", "--follow"]).command,
Some(Commands::Message(MessageCommand::Read { follow: true, .. }))
));
}
let host = parts[0].to_string();
let port = parts[1].parse::<u16>().map_err(|_| {
color_eyre::eyre::eyre!("Invalid port number: {}", parts[1])
})?;
Ok((host, port))
#[test]
fn get_defaults_to_picking_the_best_result() {
let cli = parse(&["soulseek-rs", "get", "query"]);
let Some(Commands::Get(args)) = cli.command else {
panic!("expected get");
};
assert_eq!(args.pick, Pick::Best);
assert_eq!(args.limit, 0);
assert_eq!(args.timeout, 300);
}
#[test]
fn an_absurd_wait_is_rejected_rather_than_overflowing_a_deadline() {
assert!(
Cli::try_parse_from([
"soulseek-rs",
"download",
"bob",
"a.mp3",
"--timeout",
"99999999999999"
])
.is_err()
);
assert!(
Cli::try_parse_from([
"soulseek-rs",
"--search-timeout",
"0",
"search",
"q"
])
.is_err()
);
assert!(
Cli::try_parse_from(["soulseek-rs", "browse", "bob", "-t", "20"])
.is_ok()
);
}
#[test]
fn server_address_splits_host_and_port() {
assert_eq!(
parse_server_address("server.slsknet.org:2416"),
Ok(("server.slsknet.org".to_string(), 2416))
);
assert!(parse_server_address("noport").is_err());
assert!(parse_server_address("host:70000").is_err());
assert!(parse_server_address(":2416").is_err());
}
}
+229
View File
@@ -0,0 +1,229 @@
//! One-shot commands: everything the binary can do without a terminal.
//!
//! Each command opens a [`Session`], does one job, writes records to stdout
//! and progress to stderr, and returns an exit code through [`CliError`].
pub mod social;
pub mod transfer;
use crate::cli::{Commands, MessageCommand, RoomCommand};
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
use crate::port_mapping::{self, PortMapper};
use soulseek_rs::types::{RoomEvent, RoomInfo};
use soulseek_rs::{Client, ClientSettings};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Everything a one-shot command needs that does not come from its own flags.
pub struct Ctx {
pub out: Out,
pub settings: ClientSettings,
pub download_dir: String,
pub max_concurrent_downloads: usize,
pub search_timeout: Duration,
}
/// A logged-in client plus, while the listener is enabled, the port mapping
/// that makes us reachable. Both live until the command ends.
pub struct Session {
pub client: Arc<Client>,
_mapper: Option<PortMapper>,
}
/// How long to wait for the TCP handshake with the server.
const REACH_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to wait for the login exchange once the socket is open.
const LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
impl Session {
pub fn open(ctx: &Ctx) -> CliResult<Self> {
let mapper = ctx
.settings
.enable_listen
.then(|| PortMapper::spawn(ctx.settings.listen_port));
let address = ctx.settings.server_address.to_string();
ctx.out.status(&format!("connecting to {address}…"));
reachable(&address, REACH_TIMEOUT)?;
let client = login(ctx.settings.clone(), &address)?;
ctx.out
.status(&format!("logged in as {}", ctx.settings.username));
Ok(Self {
client: Arc::new(client),
_mapper: mapper,
})
}
}
/// Check the server answers at all before handing the socket to the client.
///
/// The connect and login the library performs happen on its own threads and
/// report a refused connection only indirectly, so an unattended run would sit
/// there. One handshake up front turns that into an immediate, accurate error.
fn reachable(address: &str, timeout: Duration) -> CliResult {
let resolved: Vec<SocketAddr> = address
.to_socket_addrs()
.map_err(|e| {
CliError::connection(format!("cannot resolve {address}: {e}"))
})?
.collect();
let mut last = None;
for candidate in &resolved {
match TcpStream::connect_timeout(candidate, timeout) {
Ok(_) => return Ok(()),
Err(e) => last = Some(e),
}
}
Err(CliError::connection(match last {
Some(e) => format!("cannot reach {address}: {e}"),
None => format!("cannot reach {address}: no address resolved"),
}))
}
/// Connect and log in under a deadline.
///
/// `Client::login` blocks on a channel the library only answers once its actor
/// has a working connection, so it is run on its own thread: a server that
/// accepts the socket and then says nothing must still end the command.
fn login(settings: ClientSettings, address: &str) -> CliResult<Client> {
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut client = Client::with_settings(settings);
let outcome = match client.connect() {
Ok(()) => client.login(),
Err(e) => Err(e),
};
let _ = sender.send((client, outcome));
});
let (client, outcome) =
receiver.recv_timeout(LOGIN_TIMEOUT).map_err(|_| {
CliError::connection(format!(
"{address} accepted the connection but did not answer the \
login within {}s",
LOGIN_TIMEOUT.as_secs()
))
})?;
match outcome {
Ok(true) => Ok(client),
Ok(false) => Err(CliError::connection(
"login rejected by the server (wrong password, or the name is \
taken)",
)),
Err(e) => Err(CliError::connection(format!("login failed: {e}"))),
}
}
/// Ask for the room list and wait for the server's reply.
///
/// Events already queued are discarded first: servers push a room list at
/// login, and counting that one would answer a question we had not asked yet.
pub fn await_room_list(
client: &Client,
timeout: Duration,
poll: Duration,
) -> Option<Vec<RoomInfo>> {
let _ = client.take_room_events();
client.request_room_list().ok()?;
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let listed =
client.take_room_events().into_iter().find_map(
|event| match event {
RoomEvent::List(rooms) => Some(rooms),
_ => None,
},
);
if listed.is_some() {
return listed;
}
std::thread::sleep(poll);
}
None
}
/// Wait until the server has answered something we asked for after `send`.
///
/// The server connection is a single ordered stream served by one actor, so a
/// reply to a request queued *after* an earlier message proves that earlier
/// message reached the socket. The room list is the cheapest such request and
/// every server answers it, which lets `message send` and `room say` report
/// delivery honestly instead of sleeping and hoping.
pub fn confirm_flush(client: &Client, timeout: Duration) -> bool {
await_room_list(client, timeout, Duration::from_millis(50)).is_some()
}
pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
match command {
Commands::Search(args) => transfer::search(ctx, &args),
Commands::Download(args) => transfer::download(ctx, &args),
Commands::Get(args) => transfer::get(ctx, &args),
Commands::Browse(args) => social::browse(ctx, &args),
Commands::Room(RoomCommand::List { timeout }) => {
social::room_list(ctx, Duration::from_secs(timeout))
}
Commands::Room(RoomCommand::Say { room, message }) => {
social::room_say(ctx, &room, &message)
}
Commands::Room(RoomCommand::Listen {
room,
duration,
follow,
}) => social::room_listen(ctx, &room, listen_span(duration, follow)),
Commands::Message(MessageCommand::Send { user, message }) => {
social::message_send(ctx, &user, &message)
}
Commands::Message(MessageCommand::Read { duration, follow }) => {
social::message_read(ctx, listen_span(duration, follow))
}
// Handled before credentials are required; see main.rs.
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
}
}
/// `--follow` means "until interrupted": a span nothing will outlive.
fn listen_span(duration: u64, follow: bool) -> Option<Duration> {
(!follow).then(|| Duration::from_secs(duration))
}
/// Probe automatic port mapping. A router that does not answer is a negative
/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match.
pub fn portmap(out: &Out, port: u16) -> CliResult {
out.status(&format!("probing automatic port mapping for TCP {port}…"));
let probe = port_mapping::probe(port);
out.emit(&PortmapRecord {
ok: probe.ok,
backend: probe.backend.to_string(),
external: probe.external.clone(),
port,
});
out.status(&probe.advice);
if probe.ok {
Ok(())
} else {
// The advice is already on stderr; the error line only has to say
// what the exit code means.
Err(CliError::new(
Exit::NoResults,
format!("no router mapped TCP {port}"),
))
}
}
#[cfg(test)]
mod tests {
use super::listen_span;
use std::time::Duration;
#[test]
fn follow_listens_without_a_deadline() {
assert_eq!(listen_span(30, true), None);
assert_eq!(listen_span(30, false), Some(Duration::from_secs(30)));
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Talking to people and looking at what they share: `browse`, `room`, and
//! `message`.
use super::{Ctx, Session, confirm_flush};
use crate::cli::BrowseArgs;
use crate::output::{
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
RoomEventRecord, RoomRecord,
};
use soulseek_rs::types::RoomEvent;
use soulseek_rs::{Client, SharedDirectory};
use std::time::{Duration, Instant};
/// How long to wait for the server to acknowledge something we sent.
const FLUSH_TIMEOUT: Duration = Duration::from_secs(10);
/// Polling cadence for the request/poll parts of the client API.
const POLL: Duration = Duration::from_millis(100);
/// Join a listing's directory and file name into the remote path that
/// `download` expects — the protocol reports the two separately.
#[must_use]
pub fn full_path(directory: &str, basename: &str) -> String {
let directory = directory.trim_end_matches('\\');
if directory.is_empty() {
basename.to_string()
} else {
format!("{directory}\\{basename}")
}
}
/// Ask a peer for its share listing and wait for the answer.
pub fn fetch_listing(
client: &Client,
user: &str,
timeout: Duration,
) -> CliResult<Vec<SharedDirectory>> {
client.browse_user(user).map_err(|e| {
CliError::connection(format!("cannot ask {user} for a listing: {e}"))
})?;
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Some(directories) = client.take_browse_result(user) {
return Ok(directories);
}
std::thread::sleep(POLL);
}
Err(CliError::timeout(format!(
"{user} did not send a file list within {}s",
timeout.as_secs()
)))
}
pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
let session = Session::open(ctx)?;
ctx.out
.status(&format!("requesting {}'s shared files…", args.user));
let directories = fetch_listing(
&session.client,
&args.user,
Duration::from_secs(args.timeout),
)?;
let mut files = 0usize;
for directory in &directories {
for (basename, size) in &directory.files {
files += 1;
ctx.out.emit(&BrowseRecord {
user: args.user.clone(),
directory: directory.name.clone(),
path: full_path(&directory.name, basename),
size: *size,
});
}
}
if files == 0 {
return Err(CliError::no_results(format!(
"{} shares no files",
args.user
)));
}
Ok(())
}
pub fn room_list(ctx: &Ctx, timeout: Duration) -> CliResult {
let session = Session::open(ctx)?;
ctx.out.status("fetching the room list…");
let Some(mut rooms) =
super::await_room_list(&session.client, timeout, POLL)
else {
return Err(CliError::timeout(format!(
"the server sent no room list within {}s",
timeout.as_secs()
)));
};
if rooms.is_empty() {
return Err(CliError::no_results("the server lists no public rooms"));
}
rooms.sort_by(|a, b| {
b.user_count
.cmp(&a.user_count)
.then_with(|| a.name.cmp(&b.name))
});
for room in rooms {
ctx.out.emit(&RoomRecord {
room: room.name,
users: room.user_count,
});
}
Ok(())
}
pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {
CliError::connection(format!("cannot join {room}: {e}"))
})?;
session.client.say_in_room(room, message).map_err(|e| {
CliError::connection(format!("cannot post to {room}: {e}"))
})?;
if confirm_flush(&session.client, FLUSH_TIMEOUT) {
ctx.out.status(&format!("posted to {room}"));
return Ok(());
}
Err(CliError::timeout(format!(
"the server did not acknowledge the message to {room}"
)))
}
pub fn room_listen(ctx: &Ctx, room: &str, span: Option<Duration>) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {
CliError::connection(format!("cannot join {room}: {e}"))
})?;
stream(&ctx.out, &format!("listening to {room}"), span, || {
for event in session.client.take_room_events() {
if let Some(record) = room_event_record(room, &event) {
ctx.out.emit(&record);
}
}
});
let _ = session.client.leave_room(room);
Ok(())
}
/// Drain `next` until `span` elapses, or forever when it is `None`
/// (`--follow`, ended by the operator).
fn stream(
out: &Out,
what: &str,
span: Option<Duration>,
mut next: impl FnMut(),
) {
out.status(&match span {
Some(span) => format!("{what} for {}s…", span.as_secs()),
None => format!("{what} until interrupted…"),
});
let deadline = span.map(|span| Instant::now() + span);
while deadline.is_none_or(|deadline| Instant::now() < deadline) {
next();
std::thread::sleep(POLL);
}
}
/// Turn a room event into a record, ignoring events for other rooms and the
/// bookkeeping ones a listener has no use for.
fn room_event_record(room: &str, event: &RoomEvent) -> Option<RoomEventRecord> {
match event {
RoomEvent::Message {
room: name,
username,
message,
} if name == room => Some(RoomEventRecord {
kind: "message",
room: name.clone(),
user: username.clone(),
message: message.clone(),
}),
RoomEvent::UserJoined {
room: name,
username,
} if name == room => Some(RoomEventRecord {
kind: "join",
room: name.clone(),
user: username.clone(),
message: String::new(),
}),
RoomEvent::UserLeft {
room: name,
username,
} if name == room => Some(RoomEventRecord {
kind: "leave",
room: name.clone(),
user: username.clone(),
message: String::new(),
}),
_ => None,
}
}
pub fn message_send(ctx: &Ctx, user: &str, message: &str) -> CliResult {
let session = Session::open(ctx)?;
session
.client
.send_private_message(user, message)
.map_err(|e| {
CliError::connection(format!("cannot send to {user}: {e}"))
})?;
if confirm_flush(&session.client, FLUSH_TIMEOUT) {
ctx.out.status(&format!("sent to {user}"));
return Ok(());
}
Err(CliError::timeout(format!(
"the server did not acknowledge the message to {user}"
)))
}
pub fn message_read(ctx: &Ctx, span: Option<Duration>) -> CliResult {
let session = Session::open(ctx)?;
stream(&ctx.out, "reading private messages", span, || {
for message in session.client.take_private_messages() {
ctx.out.emit(&PrivateMessageRecord {
timestamp: message.timestamp(),
user: message.username().to_string(),
message: message.message().to_string(),
});
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_path_joins_a_directory_and_basename_with_a_backslash() {
assert_eq!(full_path("@@abc\\Music", "a.mp3"), "@@abc\\Music\\a.mp3");
}
#[test]
fn full_path_does_not_double_a_trailing_separator() {
assert_eq!(full_path("@@abc\\Music\\", "a.mp3"), "@@abc\\Music\\a.mp3");
}
#[test]
fn full_path_of_a_rootless_listing_is_the_basename() {
assert_eq!(full_path("", "a.mp3"), "a.mp3");
}
#[test]
fn only_events_for_the_listened_room_become_records() {
let mine = RoomEvent::Message {
room: "lobby".into(),
username: "bob".into(),
message: "hi".into(),
};
let elsewhere = RoomEvent::Message {
room: "other".into(),
username: "bob".into(),
message: "hi".into(),
};
assert!(room_event_record("lobby", &mine).is_some());
assert!(room_event_record("lobby", &elsewhere).is_none());
}
#[test]
fn joins_and_leaves_are_labelled() {
let joined = RoomEvent::UserJoined {
room: "lobby".into(),
username: "sue".into(),
};
let left = RoomEvent::UserLeft {
room: "lobby".into(),
username: "sue".into(),
};
assert_eq!(room_event_record("lobby", &joined).unwrap().kind, "join");
assert_eq!(room_event_record("lobby", &left).unwrap().kind, "leave");
}
#[test]
fn our_own_join_confirmation_is_not_a_record() {
let joined = RoomEvent::Joined {
room: "lobby".into(),
users: vec!["bob".into()],
};
assert!(room_event_record("lobby", &joined).is_none());
}
}
+840
View File
@@ -0,0 +1,840 @@
//! Finding files and fetching them: `search`, `download`, and `get`.
use super::{Ctx, Session, social};
use crate::cli::{DownloadArgs, GetArgs, Pick, SearchArgs, SortKey};
use crate::output::{
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
};
use soulseek_rs::utils::path::expand_tilde;
use soulseek_rs::{Client, DownloadStatus, SearchResult};
use std::collections::{HashMap, VecDeque};
use std::io::BufRead;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Soulseek file attribute codes the network agrees on.
const ATTR_BITRATE: u32 = 0;
const ATTR_DURATION: u32 = 1;
/// What to keep out of the raw result set.
#[derive(Debug, Clone, Copy, Default)]
pub struct Filter {
pub min_bitrate: Option<u32>,
pub free_slots_only: bool,
}
impl Filter {
const fn keeps(&self, candidate: &SearchRecord) -> bool {
if self.free_slots_only && !candidate.free_slot {
return false;
}
match (self.min_bitrate, candidate.bitrate) {
(Some(minimum), Some(bitrate)) => bitrate >= minimum,
// An unknown bitrate cannot satisfy a bitrate floor.
(Some(_), None) => false,
(None, _) => true,
}
}
}
/// Flatten per-peer results into individual files, dropping what `filter`
/// rejects.
#[must_use]
pub fn candidates(
results: &[SearchResult],
filter: &Filter,
) -> Vec<SearchRecord> {
let mut out = Vec::new();
for result in results {
for file in &result.files {
let candidate = SearchRecord {
user: result.username.clone(),
path: file.name.clone(),
size: file.size,
bitrate: file.attribs.get(&ATTR_BITRATE).copied(),
duration: file.attribs.get(&ATTR_DURATION).copied(),
free_slot: result.slots > 0,
speed: result.speed,
};
if filter.keeps(&candidate) {
out.push(candidate);
}
}
}
out
}
/// Order results. Every mode ends with the same user/path tiebreak so the
/// output of two identical runs is identical.
pub fn rank(items: &mut [SearchRecord], sort: SortKey) {
items.sort_by(|a, b| {
let primary = match sort {
SortKey::Best => b
.free_slot
.cmp(&a.free_slot)
.then_with(|| {
b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0))
})
.then_with(|| b.speed.cmp(&a.speed))
.then_with(|| b.size.cmp(&a.size)),
SortKey::Size => b.size.cmp(&a.size),
SortKey::Bitrate => {
b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0))
}
SortKey::Speed => b.speed.cmp(&a.speed),
};
primary
.then_with(|| a.user.cmp(&b.user))
.then_with(|| a.path.cmp(&b.path))
});
}
/// Where a remote file lands locally — the same rule the library applies, so
/// the path we report is the path that gets written.
#[must_use]
pub fn local_path(download_dir: &str, remote_path: &str) -> PathBuf {
let name = remote_path
.split(['/', '\\'])
.next_back()
.unwrap_or(remote_path);
expand_tilde(download_dir).join(name)
}
/// One file to fetch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub user: String,
pub path: String,
pub size: u64,
}
impl From<&SearchRecord> for Request {
fn from(candidate: &SearchRecord) -> Self {
Self {
user: candidate.user.clone(),
path: candidate.path.clone(),
size: candidate.size,
}
}
}
/// Filter, rank, and cap a raw result set, failing when nothing survives.
fn ranked(
results: &[SearchResult],
filter: &Filter,
sort: SortKey,
limit: usize,
query: &str,
) -> CliResult<Vec<SearchRecord>> {
let mut found = candidates(results, filter);
rank(&mut found, sort);
if limit > 0 {
found.truncate(limit);
}
if found.is_empty() {
return Err(CliError::no_results(format!("no results for '{query}'")));
}
Ok(found)
}
pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult {
let session = Session::open(ctx)?;
let results = collect(ctx, &session.client, &args.query, false)?;
let found = ranked(
&results,
&Filter {
min_bitrate: args.min_bitrate,
free_slots_only: args.free_slots,
},
args.sort,
args.limit,
&args.query,
)?;
for candidate in &found {
ctx.out.emit(candidate);
}
Ok(())
}
pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult {
let requests = if args.stdin {
let stdin = std::io::stdin();
read_requests(&mut stdin.lock(), &ctx.out)?
} else {
let (Some(user), Some(path)) = (args.user.clone(), args.path.clone())
else {
return Err(CliError::usage(
"download needs USER and PATH, or --stdin",
));
};
vec![Request {
user,
path,
size: args.size.unwrap_or(0),
}]
};
if requests.is_empty() {
return Err(CliError::no_results("no files to download"));
}
ensure_download_dir(&ctx.download_dir)?;
let session = Session::open(ctx)?;
let requests = resolve_sizes(ctx, &session.client, requests)?;
download_all(
ctx,
&session.client,
requests,
Duration::from_secs(args.timeout),
)
}
pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult {
ensure_download_dir(&ctx.download_dir)?;
let session = Session::open(ctx)?;
let results =
collect(ctx, &session.client, &args.query, args.pick == Pick::First)?;
let limit = match args.pick {
Pick::Best | Pick::First => 1,
Pick::All => args.limit,
};
let found = ranked(
&results,
&Filter {
min_bitrate: args.min_bitrate,
free_slots_only: args.free_slots,
},
SortKey::Best,
limit,
&args.query,
)?;
download_all(
ctx,
&session.client,
found.iter().map(Request::from).collect(),
Duration::from_secs(args.timeout),
)
}
/// Run a search and hand back the raw per-peer results.
///
/// `stop_early` returns as soon as anything arrives, which is what
/// `get --pick first` wants; otherwise the search runs its full span because
/// late responders are often the better sources.
fn collect(
ctx: &Ctx,
client: &Arc<Client>,
query: &str,
stop_early: bool,
) -> CliResult<Vec<SearchResult>> {
let timeout = ctx.search_timeout;
ctx.out.status(&format!(
"searching for '{query}' ({}s)…",
timeout.as_secs()
));
if !stop_early {
return client
.search(query, timeout)
.map_err(|e| CliError::connection(format!("search failed: {e}")));
}
let cancel = Arc::new(AtomicBool::new(false));
let worker = {
let client = Arc::clone(client);
let query = query.to_string();
let cancel = Arc::clone(&cancel);
std::thread::spawn(move || {
client.search_with_cancel(&query, timeout, Some(cancel))
})
};
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if client.get_search_results_count(query) > 0 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
cancel.store(true, Ordering::Relaxed);
let _ = worker.join();
Ok(client.get_search_results(query))
}
/// Drop requests that would collide with one already queued.
///
/// Two collisions are possible and both lose data silently: the library keys
/// downloads by a hash of the remote path, so the same path from two peers
/// shadows itself, and two different paths sharing a basename would be written
/// to the same local file. Keep the first (best-ranked) of each and say what
/// was dropped rather than transferring less than asked.
fn dedupe(
requests: impl IntoIterator<Item = Request>,
download_dir: &str,
out: &Out,
) -> Vec<Request> {
let mut paths: HashMap<String, String> = HashMap::new();
let mut targets: HashMap<PathBuf, String> = HashMap::new();
let mut kept = Vec::new();
for request in requests {
if let Some(user) = paths.get(&request.path) {
out.warn(&format!(
"skipping {} from {}: same remote path already queued from \
{user}",
request.path, request.user
));
continue;
}
let target = local_path(download_dir, &request.path);
if let Some(other) = targets.get(&target) {
out.warn(&format!(
"skipping {} from {}: would overwrite the file already queued \
from {other}",
request.path, request.user
));
continue;
}
paths.insert(request.path.clone(), request.user.clone());
targets.insert(target, request.user.clone());
kept.push(request);
}
kept
}
/// A transfer whose size we do not know would be finalized against zero bytes
/// and saved empty, so look it up in the peer's share listing first.
fn resolve_sizes(
ctx: &Ctx,
client: &Client,
requests: Vec<Request>,
) -> CliResult<Vec<Request>> {
if requests.iter().all(|request| request.size > 0) {
return Ok(requests);
}
let mut sizes: HashMap<String, HashMap<String, u64>> = HashMap::new();
let mut resolved = Vec::with_capacity(requests.len());
for mut request in requests {
if request.size > 0 {
resolved.push(request);
continue;
}
if !sizes.contains_key(&request.user) {
ctx.out.status(&format!(
"looking up the size of {} in {}'s shares…",
request.path, request.user
));
let listing = social::fetch_listing(
client,
&request.user,
Duration::from_secs(20),
)?;
sizes.insert(
request.user.clone(),
listing
.iter()
.flat_map(|directory| {
directory.files.iter().map(|(basename, size)| {
(
social::full_path(&directory.name, basename),
*size,
)
})
})
.collect(),
);
}
request.size = sizes
.get(&request.user)
.and_then(|files| files.get(&request.path))
.copied()
.ok_or_else(|| {
CliError::no_results(format!(
"{} does not share '{}' — pass --size to download it \
anyway",
request.user, request.path
))
})?;
resolved.push(request);
}
Ok(resolved)
}
fn ensure_download_dir(download_dir: &str) -> CliResult {
let path = expand_tilde(download_dir);
std::fs::create_dir_all(&path).map_err(|e| {
CliError::usage(format!(
"cannot use download directory {}: {e}",
path.display()
))
})
}
/// Fetch every request, at most `max_concurrent_downloads` at a time, emitting
/// each result as it lands. Any failure fails the command.
fn download_all(
ctx: &Ctx,
client: &Arc<Client>,
requests: Vec<Request>,
timeout: Duration,
) -> CliResult {
let requests = dedupe(requests, &ctx.download_dir, &ctx.out);
let workers = ctx.max_concurrent_downloads.clamp(1, requests.len().max(1));
let queue = Mutex::new(VecDeque::from(requests));
let (sender, receiver) = channel();
let mut failures = std::thread::scope(|scope| {
for _ in 0..workers {
let sender = sender.clone();
let queue = &queue;
scope.spawn(move || {
while let Some(request) =
queue.lock().ok().and_then(|mut q| q.pop_front())
{
let outcome = download_one(
client,
&ctx.out,
&request,
&ctx.download_dir,
timeout,
);
if sender.send((request, outcome)).is_err() {
break;
}
}
});
}
drop(sender);
let mut failures = Vec::new();
for (request, outcome) in receiver {
match outcome {
Ok(record) => ctx.out.emit(&record),
Err(error) => {
ctx.out.error(&format!("{}: {error}", request.path));
failures.push(error);
}
}
}
failures
});
if failures.is_empty() {
return Ok(());
}
if failures.len() == 1 {
return Err(failures.remove(0));
}
let n = failures.len();
if failures.iter().all(|f| f.exit == Exit::Timeout) {
return Err(CliError::new(
Exit::Timeout,
format!("{n} downloads timed out"),
));
}
Err(CliError::transfer(format!("{n} downloads failed")))
}
/// Queue one transfer and watch it to a verdict. The library reports failure
/// on the status channel rather than through the call that starts the
/// transfer, so the channel is the only thing worth believing.
fn download_one(
client: &Client,
out: &Out,
request: &Request,
download_dir: &str,
timeout: Duration,
) -> CliResult<DownloadRecord> {
// Clear any earlier attempt: entries are keyed by a hash of the remote
// path, so a stale one would shadow this transfer.
let _ = client.remove_download(&request.user, &request.path);
let (_download, updates) = client
.download(
request.path.clone(),
request.user.clone(),
request.size,
download_dir.to_string(),
)
.map_err(|e| CliError::transfer(format!("cannot start: {e}")))?;
out.status(&format!(
"requesting {} from {} ({} bytes)…",
request.path, request.user, request.size
));
let deadline = Instant::now() + timeout;
let mut last_report = Instant::now();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
let _ = client.remove_download(&request.user, &request.path);
return Err(CliError::timeout(format!(
"gave up after {}s",
timeout.as_secs()
)));
}
match updates.recv_timeout(remaining.min(Duration::from_millis(250))) {
Ok(DownloadStatus::Completed) => {
let file = local_path(download_dir, &request.path);
return Ok(DownloadRecord {
user: request.user.clone(),
path: request.path.clone(),
size: request.size,
file: file.display().to_string(),
});
}
Ok(DownloadStatus::Failed(reason)) => {
let _ = client.remove_download(&request.user, &request.path);
return Err(CliError::transfer(
reason.unwrap_or_else(|| "the transfer failed".to_string()),
));
}
Ok(DownloadStatus::Queued) => {
out.status(&format!(
"queued behind {}'s uploads",
request.user
));
}
Ok(DownloadStatus::InProgress {
bytes_downloaded,
total_bytes,
speed_bytes_per_sec,
}) => {
if last_report.elapsed() >= Duration::from_secs(1) {
last_report = Instant::now();
out.status(&format!(
"{}: {bytes_downloaded}/{total_bytes} bytes ({:.0} KB/s)",
request.path,
speed_bytes_per_sec / 1024.0
));
}
}
Ok(DownloadStatus::Paused { .. } | DownloadStatus::TimedOut)
| Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
let _ = client.remove_download(&request.user, &request.path);
return Err(CliError::transfer(
"the transfer ended without reporting a result",
));
}
}
}
}
/// Parse `search`/`browse` output back into download requests.
///
/// Both shapes this program emits are accepted: a JSON object per line, or
/// tab-separated text whose first field is the user and last field is the
/// remote path, with the size in between when there is one.
fn read_requests(
input: &mut impl BufRead,
out: &Out,
) -> CliResult<Vec<Request>> {
let mut requests = Vec::new();
for (number, line) in input.lines().enumerate() {
let line = line
.map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?;
match parse_request(&line) {
Ok(Some(request)) => requests.push(request),
Ok(None) => {}
Err(reason) => {
out.warn(&format!("stdin line {}: {reason}", number + 1));
}
}
}
Ok(requests)
}
/// `Ok(None)` for a blank line, `Err` for something unusable.
fn parse_request(line: &str) -> Result<Option<Request>, String> {
let line = line.trim_end_matches(['\r', '\n']);
if line.trim().is_empty() {
return Ok(None);
}
let (user, path, size) = if line.trim_start().starts_with('{') {
let record: JsonRequest = serde_json::from_str(line.trim())
.map_err(|e| format!("invalid JSON record: {e}"))?;
(record.user, record.path, record.size)
} else {
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 2 {
return Err(
"expected tab-separated USER…PATH or a JSON record".to_string()
);
}
let size = if fields.len() >= 3 {
fields[1].trim().parse().unwrap_or(0)
} else {
0
};
(
fields[0].trim().to_string(),
fields[fields.len() - 1].trim().to_string(),
size,
)
};
if user.is_empty() || path.is_empty() {
return Err("user and path must not be empty".to_string());
}
Ok(Some(Request { user, path, size }))
}
/// The subset of a `search`/`browse` JSON record a download needs. Extra
/// fields are ignored, so a record enriched by `jq` still parses.
#[derive(serde::Deserialize)]
struct JsonRequest {
user: String,
path: String,
#[serde(default)]
size: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use soulseek_rs::File;
use std::collections::HashMap;
fn file(name: &str, size: u64, bitrate: Option<u32>) -> File {
let mut attribs = HashMap::new();
if let Some(bitrate) = bitrate {
attribs.insert(ATTR_BITRATE, bitrate);
attribs.insert(ATTR_DURATION, 200);
}
File {
username: String::new(),
name: name.to_string(),
size,
attribs,
}
}
fn result(
user: &str,
slots: u8,
speed: u32,
files: Vec<File>,
) -> SearchResult {
SearchResult {
token: 1,
files,
slots,
speed,
username: user.to_string(),
}
}
fn sample() -> Vec<SearchResult> {
vec![
result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]),
result("busy", 0, 900, vec![file("b\\best.mp3", 90, Some(320))]),
result(
"fast",
1,
500,
vec![
file("c\\good.mp3", 50, Some(256)),
file("c\\unknown.mp3", 70, None),
],
),
]
}
#[test]
fn candidates_flatten_every_file_of_every_peer() {
let found = candidates(&sample(), &Filter::default());
assert_eq!(found.len(), 4);
assert!(found.iter().any(|c| c.user == "fast" && c.size == 70));
}
#[test]
fn candidates_carry_bitrate_duration_and_slot_state() {
let found = candidates(&sample(), &Filter::default());
let best = found.iter().find(|c| c.path == "b\\best.mp3").unwrap();
assert_eq!(best.bitrate, Some(320));
assert_eq!(best.duration, Some(200));
assert!(!best.free_slot);
assert_eq!(best.speed, 900);
}
#[test]
fn min_bitrate_drops_lower_and_unknown_bitrates() {
let filter = Filter {
min_bitrate: Some(256),
free_slots_only: false,
};
let found = candidates(&sample(), &filter);
let paths: Vec<&str> = found.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, ["b\\best.mp3", "c\\good.mp3"]);
}
#[test]
fn free_slots_only_keeps_peers_advertising_a_slot() {
let filter = Filter {
min_bitrate: None,
free_slots_only: true,
};
let found = candidates(&sample(), &filter);
assert!(found.iter().all(|c| c.free_slot));
assert!(found.iter().all(|c| c.user != "busy"));
}
#[test]
fn best_prefers_a_free_slot_over_a_higher_bitrate() {
let mut found = candidates(&sample(), &Filter::default());
rank(&mut found, SortKey::Best);
assert_eq!(found[0].path, "c\\good.mp3");
assert_eq!(found[0].user, "fast");
}
#[test]
fn sort_modes_lead_with_their_own_key() {
let mut found = candidates(&sample(), &Filter::default());
rank(&mut found, SortKey::Size);
assert_eq!(found[0].size, 90);
rank(&mut found, SortKey::Bitrate);
assert_eq!(found[0].bitrate, Some(320));
rank(&mut found, SortKey::Speed);
assert_eq!(found[0].speed, 900);
}
#[test]
fn ranking_is_deterministic_for_equivalent_results() {
let tie = vec![
result("zed", 1, 1, vec![file("x.mp3", 1, Some(128))]),
result("amy", 1, 1, vec![file("x.mp3", 1, Some(128))]),
];
let mut found = candidates(&tie, &Filter::default());
rank(&mut found, SortKey::Best);
assert_eq!(found[0].user, "amy");
}
#[test]
fn local_path_uses_the_last_segment_of_a_windows_style_path() {
assert_eq!(
local_path("/music", "@@abc\\Albums\\01 - Track.mp3"),
PathBuf::from("/music/01 - Track.mp3")
);
assert_eq!(
local_path("/music", "plain.mp3"),
PathBuf::from("/music/plain.mp3")
);
assert_eq!(
local_path("/music", "unix/style/file.mp3"),
PathBuf::from("/music/file.mp3")
);
}
#[test]
fn stdin_accepts_the_json_records_search_emits() {
let request = parse_request(
r#"{"user":"bob","path":"a\\b.mp3","size":42,"bitrate":320}"#,
)
.unwrap()
.unwrap();
assert_eq!(request.user, "bob");
assert_eq!(request.path, "a\\b.mp3");
assert_eq!(request.size, 42);
}
#[test]
fn stdin_accepts_the_text_records_search_emits() {
let request = parse_request("bob\t4096\t320\t@@x\\track.mp3")
.unwrap()
.unwrap();
assert_eq!(request.user, "bob");
assert_eq!(request.size, 4096);
assert_eq!(request.path, "@@x\\track.mp3");
}
#[test]
fn stdin_accepts_a_bare_user_and_path_pair() {
let request = parse_request("bob\t@@x\\track.mp3").unwrap().unwrap();
assert_eq!(request.user, "bob");
assert_eq!(request.path, "@@x\\track.mp3");
assert_eq!(request.size, 0);
}
#[test]
fn stdin_skips_blank_lines_and_rejects_junk() {
assert_eq!(parse_request(" ").unwrap(), None);
assert!(parse_request("nonsense").is_err());
assert!(parse_request("{\"path\":\"a\"}").is_err());
assert!(parse_request("\tpath-only").is_err());
}
#[test]
fn a_bad_line_does_not_discard_the_good_ones() {
let input = "bob\t10\t320\ta.mp3\nnonsense\n\nsue\tb.mp3\n";
let out = Out::new(false, true);
let requests = read_requests(&mut input.as_bytes(), &out).unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].user, "sue");
}
fn request(user: &str, path: &str) -> Request {
Request {
user: user.into(),
path: path.into(),
size: 1,
}
}
#[test]
fn duplicate_remote_paths_are_dropped_because_the_store_keys_by_path() {
let out = Out::new(false, true);
let kept = dedupe(
vec![
request("a", "same.mp3"),
request("b", "same.mp3"),
request("c", "other.mp3"),
],
"/music",
&out,
);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].user, "a");
assert_eq!(kept[1].path, "other.mp3");
}
#[test]
fn requests_that_would_overwrite_the_same_local_file_are_dropped() {
// Different remote paths, same basename: both would be written to
// /music/track.mp3 and the slower transfer would clobber the other.
let out = Out::new(false, true);
let kept = dedupe(
vec![
request("a", "@@one\\album\\track.mp3"),
request("b", "@@two\\other\\track.mp3"),
],
"/music",
&out,
);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].user, "a");
}
#[test]
fn stdin_rejects_a_json_record_with_empty_fields() {
assert!(parse_request(r#"{"user":"","path":"a.mp3"}"#).is_err());
assert!(parse_request(r#"{"user":"bob","path":""}"#).is_err());
}
#[test]
fn stdin_ignores_extra_json_fields_a_filter_may_have_added() {
let request =
parse_request(r#"{"user":"bob","path":"a.mp3","size":7,"tag":1}"#)
.unwrap()
.unwrap();
assert_eq!(request.size, 7);
}
}
-14
View File
@@ -1,14 +0,0 @@
pub struct SearchConfig {
pub username: String,
pub password: String,
pub server_host: String,
pub server_port: u16,
pub enable_listener: bool,
pub listener_port: u16,
pub query: String,
pub timeout: u64,
pub download_dir: String,
pub verbose: u8,
pub max_concurrent_downloads: usize,
pub shared_directories: Vec<String>,
}
+162 -417
View File
@@ -1,149 +1,177 @@
mod cli;
mod config;
mod commands;
mod directories;
mod models;
mod output;
mod persist;
mod port_mapping;
mod ui;
use clap::Parser;
use cli::{Cli, Commands, parse_server_address};
use color_eyre::Result;
use config::SearchConfig;
use soulseek_rs::{Client, ClientSettings, PeerAddress};
use std::{
env,
sync::{Arc, atomic::AtomicBool},
time::Duration,
};
use ui::{FileSelector, launch_main_tui, show_multi_download_progress};
use commands::Ctx;
use output::{CliError, CliResult, Exit, Out};
use soulseek_rs::{ClientSettings, PeerAddress};
use std::io::{BufRead, IsTerminal};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use std::{env, io};
use ui::launch_main_tui;
fn main() -> Result<()> {
fn main() -> ExitCode {
dotenv::dotenv().ok();
color_eyre::install()?;
let _ = color_eyre::install();
let cli = Cli::parse();
init_logging(&cli);
// Layer CLI/env values over config.toml over built-in defaults.
let config_path = persist::paths::config_file();
let out = Out::new(cli.json, cli.quiet);
match run(cli, &out) {
Ok(()) => Exit::Ok.into(),
Err(error) => {
out.error(&error.message);
error.exit.into()
}
}
}
fn run(mut cli: Cli, out: &Out) -> CliResult {
let config_path = config_path(&cli);
let file_config = match &config_path {
Some(path) => persist::config::FileConfig::load(path)?,
Some(path) => persist::config::FileConfig::load(path)
.map_err(|e| CliError::usage(e.to_string()))?,
None => persist::config::FileConfig::default(),
};
let resolved = persist::config::resolve(&cli, &file_config);
// `portmap` is a local network diagnostic; it needs no server credentials,
// so handle it before requiring a username/password.
if matches!(cli.command, Some(Commands::Portmap)) {
println!(
"Testing automatic port mapping for TCP {}…",
resolved.listener_port
);
println!("{}", port_mapping::diagnose(resolved.listener_port));
return Ok(());
}
// The interactive TUI handles missing credentials itself with a
// login/registration screen; only the one-shot subcommands hard-require
// them up front.
if cli.command.is_none() {
let Some(command) = cli.command.take() else {
return run_default_tui(&cli, &resolved, config_path, &file_config);
};
// A local network probe needs no account, so it runs before credentials
// are demanded.
if matches!(command, Commands::Portmap) {
return commands::portmap(out, resolved.listener_port);
}
let ctx = context(&cli, &resolved, out)?;
commands::run(&ctx, command)
}
/// Which config file to read: an explicit `--config`, the platform default,
/// or none at all under `--no-config`.
fn config_path(cli: &Cli) -> Option<PathBuf> {
if cli.no_config {
return None;
}
cli.config.clone().or_else(persist::paths::config_file)
}
/// Assemble everything a one-shot command needs, demanding the credentials
/// the interactive login screen would otherwise ask for.
fn context(
cli: &Cli,
resolved: &persist::config::Resolved,
out: &Out,
) -> CliResult<Ctx> {
let username = resolved.username.clone().ok_or_else(|| {
color_eyre::eyre::eyre!(
"Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml"
CliError::usage(
"no username: pass --username, set SOULSEEK_USERNAME, or put one \
in config.toml",
)
})?;
let password = password(cli, resolved)?.ok_or_else(|| {
CliError::usage(
"no password: pass --password/--password-stdin, set \
SOULSEEK_PASSWORD, store one in the OS keychain, or set \
password_cmd",
)
})?;
let (host, port) =
parse_server_address(&resolved.server).map_err(CliError::usage)?;
let password = persist::secret::resolve_password(
Ok(Ctx {
out: out.clone(),
settings: ClientSettings {
username,
password,
server_address: PeerAddress::new(host, port),
enable_listen: !resolved.disable_listener,
listen_port: resolved.listener_port,
shared_directories: shared_directories(resolved, out),
},
download_dir: resolved.download_dir.clone(),
max_concurrent_downloads: resolved.max_concurrent_downloads,
search_timeout: Duration::from_secs(resolved.search_timeout),
})
}
/// Resolve the password: stdin beats the flag/env, which beats the keychain,
/// which beats `password_cmd`.
fn password(
cli: &Cli,
resolved: &persist::config::Resolved,
) -> CliResult<Option<String>> {
if cli.password_stdin {
let mut line = String::new();
io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?;
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
return Err(CliError::usage("no password on stdin"));
}
return Ok(Some(line.to_string()));
}
Ok(persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&persist::secret::KeyringStore,
)
.ok_or_else(|| {
color_eyre::eyre::eyre!(
"Password required: use --password, set SOULSEEK_PASSWORD, or store one in the OS keychain"
)
})?;
let (server_host, server_port) = parse_server_address(&resolved.server)?;
// Resolve the configured shared/upload directories up front; a
// misconfigured one is a warning, not a fatal error.
let shared_directories =
directories::resolve_shared_directories(&resolved.shared_dirs);
let settings = ClientSettings {
username: username.clone(),
password: password.clone(),
server_address: PeerAddress::new(server_host.clone(), server_port),
enable_listen: !resolved.disable_listener,
listen_port: resolved.listener_port,
shared_directories: shared_directories.clone(),
};
match cli.command {
Some(Commands::Search {
query,
timeout,
download_dir,
max_concurrent_downloads,
}) => {
let config = SearchConfig {
username,
password,
server_host,
server_port,
enable_listener: !resolved.disable_listener,
listener_port: resolved.listener_port,
query,
timeout,
download_dir,
verbose: cli.verbose,
max_concurrent_downloads,
shared_directories,
};
search_and_download(config)
}
Some(Commands::Message {
username: recipient,
message,
}) => send_private_message(&settings, &recipient, &message),
Some(Commands::Browse { username: target }) => {
browse_user(&settings, &target)
}
Some(Commands::Rooms) => list_rooms(&settings),
Some(Commands::Chat {
room,
message,
listen_secs,
}) => chat_room(&settings, &room, message.as_deref(), listen_secs),
// Portmap is handled before the credential check; None returns early
// into run_default_tui above.
Some(Commands::Portmap) | None => unreachable!(),
}
))
}
fn init_logging(cli: &Cli) {
let log_level = match cli.verbose {
0 => "ERROR",
1 => "WARN",
2 => "INFO",
3 => "DEBUG",
_ => "TRACE",
};
// SAFETY: Called before any threads are spawned
unsafe { env::set_var("LOG_LEVEL", log_level) };
/// Validate the configured shares. A bad entry is a warning, not a failure:
/// the client simply shares less.
fn shared_directories(
resolved: &persist::config::Resolved,
out: &Out,
) -> Vec<String> {
let mut valid = Vec::new();
for dir in &resolved.shared_dirs {
match directories::resolve_shared_directory(Some(dir)) {
Ok(Some(path)) => valid.push(path.display().to_string()),
Ok(None) => {}
Err(e) => out.warn(&format!("ignoring shared directory: {e}")),
}
}
valid
}
if let Some(log_file) = &cli.log_file {
// SAFETY: Called before any threads are spawned
unsafe {
env::set_var("LOG_FILE", log_file.to_string_lossy().to_string());
};
/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from
/// the environment is honoured, and otherwise logging stays at errors only so
/// stderr carries this program's own output and nothing else.
fn init_logging(cli: &Cli) {
let level = match cli.verbose {
0 => (env::var_os("LOG_LEVEL").is_none()
&& env::var_os("RUST_LOG").is_none())
.then_some("ERROR"),
1 => Some("WARN"),
2 => Some("INFO"),
3 => Some("DEBUG"),
_ => Some("TRACE"),
};
// SAFETY: called before any threads are spawned.
unsafe {
if let Some(level) = level {
env::set_var("LOG_LEVEL", level);
}
if let Some(log_file) = &cli.log_file {
env::set_var("LOG_FILE", log_file.as_os_str());
}
}
}
@@ -154,37 +182,41 @@ fn init_logging(cli: &Cli) {
fn run_default_tui(
cli: &Cli,
resolved: &persist::config::Resolved,
config_path: Option<std::path::PathBuf>,
config_path: Option<PathBuf>,
file_config: &persist::config::FileConfig,
) -> Result<()> {
) -> CliResult {
use ratatui::crossterm::{
event::EnableMouseCapture,
execute,
terminal::{Clear, ClearType},
};
let (server_host, server_port) = parse_server_address(&resolved.server)?;
// Without a terminal the TUI would panic on its first draw. A script that
// reached this point wanted a subcommand, so say that instead of crashing.
if !std::io::stdout().is_terminal() {
return Err(CliError::usage(
"the interactive interface needs a terminal; run a subcommand for \
scripted use (see --help)",
));
}
let out = Out::new(false, false);
let (server_host, server_port) =
parse_server_address(&resolved.server).map_err(CliError::usage)?;
// Make sure the download folder exists up front: first-run defaults point
// download and shared at the same fresh Downloads/Soulseek folder, and
// shared-directory validation requires an existing path.
if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) {
eprintln!(
"⚠️ Could not create download directory {}: {e}",
out.warn(&format!(
"could not create download directory {}: {e}",
resolved.download_dir
);
));
}
let shared_directories =
directories::resolve_shared_directories(&resolved.shared_dirs);
let shared_directories = shared_directories(resolved, &out);
let secret_store = persist::secret::KeyringStore;
let initial_password = persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&secret_store,
);
let initial_password = password(cli, resolved)?;
// Enable logger buffering BEFORE connection to prevent log artifacts
soulseek_rs::utils::logger::enable_buffering();
@@ -229,7 +261,7 @@ fn run_default_tui(
Err(e) => {
ratatui::restore();
soulseek_rs::utils::logger::disable_buffering();
return Err(e);
return Err(CliError::new(Exit::Failure, e.to_string()));
}
};
@@ -246,6 +278,7 @@ fn run_default_tui(
Duration::from_secs(resolved.search_timeout),
store,
)
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
}
/// After a successful login, remember the username in config.toml and — when
@@ -253,7 +286,7 @@ fn run_default_tui(
/// best-effort: failing to persist must not break a working session.
fn persist_credentials(
outcome: &ui::login::LoginOutcome,
config_path: Option<std::path::PathBuf>,
config_path: Option<PathBuf>,
file_config: &persist::config::FileConfig,
secret_store: &dyn persist::secret::SecretStore,
) {
@@ -272,291 +305,3 @@ fn persist_credentials(
eprintln!("⚠️ Could not store password in keychain: {e}");
}
}
fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> {
use std::time::Instant;
let _port_mapper = settings
.enable_listen
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
let mut client = Client::with_settings(settings.clone());
client
.connect()
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
if !client
.login()
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
{
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
}
client
.browse_user(target)
.map_err(|e| color_eyre::eyre::eyre!("Failed to browse: {}", e))?;
println!("📂 Requesting shared files from {target}...");
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if let Some(directories) = client.take_browse_result(target) {
if directories.is_empty() {
println!("({target} shares nothing)");
}
for directory in directories {
println!("\n{}/", directory.name);
for (name, size) in directory.files {
println!(" {name} ({size} bytes)");
}
}
return Ok(());
}
std::thread::sleep(Duration::from_millis(200));
}
Err(color_eyre::eyre::eyre!(
"Timed out waiting for {target}'s file list"
))
}
/// Connect and log in, returning the ready client or a descriptive error.
fn connect_and_login(settings: &ClientSettings) -> Result<Client> {
let mut client = Client::with_settings(settings.clone());
client
.connect()
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
if !client
.login()
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
{
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
}
Ok(client)
}
fn list_rooms(settings: &ClientSettings) -> Result<()> {
use std::time::Instant;
let client = connect_and_login(settings)?;
client
.request_room_list()
.map_err(|e| color_eyre::eyre::eyre!("Failed to list rooms: {}", e))?;
println!("📋 Fetching room list...");
let deadline = Instant::now() + Duration::from_secs(5);
let mut rooms = client.room_list();
while rooms.is_empty() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(200));
rooms = client.room_list();
}
rooms.sort_by(|a, b| {
b.user_count
.cmp(&a.user_count)
.then_with(|| a.name.cmp(&b.name))
});
if rooms.is_empty() {
println!("(no public rooms reported)");
} else {
println!("{:>6} room", "users");
for room in rooms {
println!("{:>6} {}", room.user_count, room.name);
}
}
Ok(())
}
fn chat_room(
settings: &ClientSettings,
room: &str,
message: Option<&str>,
listen_secs: u64,
) -> Result<()> {
use soulseek_rs::types::RoomEvent;
use std::time::Instant;
let client = connect_and_login(settings)?;
client
.join_room(room)
.map_err(|e| color_eyre::eyre::eyre!("Failed to join room: {}", e))?;
if let Some(text) = message {
client
.say_in_room(room, text)
.map_err(|e| color_eyre::eyre::eyre!("Failed to say: {}", e))?;
// Let the server actor flush before we drop the client.
std::thread::sleep(Duration::from_millis(500));
println!("💬 {room}: {text}");
return Ok(());
}
println!(
"💬 Joined {room}. Listening for {listen_secs}s (Ctrl-C to quit)..."
);
let deadline = Instant::now() + Duration::from_secs(listen_secs);
while Instant::now() < deadline {
for event in client.take_room_events() {
match event {
RoomEvent::Message {
room: r,
username,
message,
} if r == room => println!("<{username}> {message}"),
RoomEvent::UserJoined { room: r, username } if r == room => {
println!("→ {username} joined");
}
RoomEvent::UserLeft { room: r, username } if r == room => {
println!("← {username} left");
}
_ => {}
}
}
std::thread::sleep(Duration::from_millis(200));
}
let _ = client.leave_room(room);
Ok(())
}
fn send_private_message(
settings: &ClientSettings,
recipient: &str,
message: &str,
) -> Result<()> {
let mut client = Client::with_settings(settings.clone());
client
.connect()
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
if !client
.login()
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
{
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
}
client
.send_private_message(recipient, message)
.map_err(|e| {
color_eyre::eyre::eyre!("Failed to send message: {}", e)
})?;
// The send is dispatched asynchronously; give the server actor a moment to
// flush it to the socket before we drop the client and exit.
std::thread::sleep(Duration::from_millis(500));
println!("✉️ Message sent to {recipient}");
Ok(())
}
fn search_and_download(config: SearchConfig) -> Result<()> {
if config.verbose > 0 {
println!(
"🔌 Connecting to Soulseek server {}:{}...",
config.server_host, config.server_port
);
}
let settings = ClientSettings {
username: config.username.clone(),
password: config.password.clone(),
server_address: PeerAddress::new(
config.server_host.clone(),
config.server_port,
),
enable_listen: config.enable_listener,
listen_port: config.listener_port,
shared_directories: config.shared_directories.clone(),
};
let _port_mapper = settings
.enable_listen
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
let mut client = Client::with_settings(settings);
client
.connect()
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
client
.login()
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?;
if config.verbose > 0 {
println!("🔍 Searching for: {}", config.query);
}
// Wrap client in Arc for sharing with FileSelector
let client = Arc::new(client);
let cancel_flag = Arc::new(AtomicBool::new(false));
let search_client = client.clone();
let search_query = config.query.clone();
let search_timeout = Duration::from_secs(config.timeout);
let search_cancel = cancel_flag.clone();
let _search_handle = std::thread::spawn(move || {
search_client.search_with_cancel(
&search_query,
search_timeout,
Some(search_cancel),
)
});
// Launch FileSelector immediately with live search enabled
let terminal = ratatui::init();
let mut file_selector = FileSelector::new_with_live_search(
client.clone(),
config.query.clone(),
Duration::from_secs(config.timeout),
cancel_flag.clone(),
);
let (terminal, selected_indices) = file_selector.run(terminal)?;
// Cancel search thread - no need to wait for it
cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed);
let results = client.get_search_results(&config.query);
if selected_indices.is_empty() {
ratatui::restore();
println!("❌ No files selected for download");
return Ok(());
}
// Directly map selected indices to file data (skip expensive all_files conversion)
let selected_files: Vec<_> = selected_indices
.iter()
.filter_map(|&idx| {
let mut current = 0;
for result in &results {
let next = current + result.files.len();
if idx < next {
let file_idx = idx - current;
let file = &result.files[file_idx];
return Some((
file.name.clone(),
result.username.clone(),
file.size,
));
}
current = next;
}
None
})
.collect();
if selected_files.is_empty() {
ratatui::restore();
println!("❌ No files found in search results");
return Ok(());
}
// Show multi-download progress view immediately (initializes downloads asynchronously)
show_multi_download_progress(
terminal,
client,
selected_files,
config.download_dir,
config.max_concurrent_downloads,
)?;
println!("\n✨ Download complete!");
Ok(())
}
+436
View File
@@ -0,0 +1,436 @@
//! Output discipline for the one-shot commands.
//!
//! stdout carries records and nothing else, stderr carries progress, and the
//! process exit code carries the verdict. Records render either as
//! newline-delimited JSON (`--json`) or as tab-separated text, so both `jq`
//! and `cut` are first-class consumers.
use serde::Serialize;
use std::io::{IsTerminal, Write};
/// Documented exit statuses. Anything a wrapper script needs to branch on has
/// its own code; clap already uses 2 for argument errors, so config problems
/// share it.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Exit {
Ok = 0,
/// Something unexpected went wrong (also the panic/TUI bucket).
Failure = 1,
/// Bad arguments, missing credentials, unusable configuration.
Usage = 2,
/// Could not reach the server, or the server rejected the login.
Connection = 3,
/// The operation succeeded but produced nothing.
NoResults = 4,
/// Gave up waiting for a response or a transfer.
Timeout = 5,
/// A transfer started but did not complete.
Transfer = 6,
}
impl Exit {
#[must_use]
pub const fn code(self) -> u8 {
self as u8
}
}
impl From<Exit> for std::process::ExitCode {
fn from(exit: Exit) -> Self {
Self::from(exit.code())
}
}
/// A failure carrying the exit code it should produce.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliError {
pub exit: Exit,
pub message: String,
}
impl CliError {
pub fn new(exit: Exit, message: impl Into<String>) -> Self {
Self {
exit,
message: message.into(),
}
}
pub fn usage(message: impl Into<String>) -> Self {
Self::new(Exit::Usage, message)
}
pub fn connection(message: impl Into<String>) -> Self {
Self::new(Exit::Connection, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(Exit::Timeout, message)
}
pub fn transfer(message: impl Into<String>) -> Self {
Self::new(Exit::Transfer, message)
}
pub fn no_results(message: impl Into<String>) -> Self {
Self::new(Exit::NoResults, message)
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CliError {}
pub type CliResult<T = ()> = Result<T, CliError>;
/// One line of machine-readable output.
pub trait Record: Serialize {
/// Tab-separated rendering. Fields must already be free of tabs and
/// newlines — use [`sanitize`].
fn text(&self) -> String;
}
/// Strip the field and record separators out of peer-supplied text so one
/// record always occupies exactly one line with stable columns.
#[must_use]
pub fn sanitize(value: &str) -> String {
value.replace(['\t', '\r', '\n'], " ")
}
/// Where records go and whether progress is shown.
#[derive(Clone)]
pub struct Out {
json: bool,
quiet: bool,
color: bool,
}
impl Out {
#[must_use]
pub fn new(json: bool, quiet: bool) -> Self {
Self {
json,
quiet,
color: std::env::var_os("NO_COLOR").is_none()
&& std::io::stderr().is_terminal(),
}
}
/// Write one record to stdout.
pub fn emit<R: Record>(&self, record: &R) {
let line = if self.json {
serde_json::to_string(record)
.expect("a record is plain data and always serializes")
} else {
record.text()
};
Self::write_line(&line);
}
/// Progress and narration. Suppressed by `--quiet`, never on stdout.
pub fn status(&self, message: &str) {
if !self.quiet {
let _ = writeln!(std::io::stderr(), "{message}");
}
}
/// A problem worth reporting that is not fatal. Shown even when quiet,
/// because silently doing less than asked is worse than a stray line.
pub fn warn(&self, message: &str) {
let _ = if self.color {
writeln!(std::io::stderr(), "\x1b[33mwarning:\x1b[0m {message}")
} else {
writeln!(std::io::stderr(), "warning: {message}")
};
}
pub fn error(&self, message: &str) {
let _ = if self.color {
writeln!(std::io::stderr(), "\x1b[31merror:\x1b[0m {message}")
} else {
writeln!(std::io::stderr(), "error: {message}")
};
}
// a closed downstream (`| head -1`) is a normal end for a
// pipeline, so exit quietly instead of letting println! panic on EPIPE.
fn write_line(line: &str) {
if writeln!(std::io::stdout(), "{line}").is_err() {
std::process::exit(Exit::Ok.code().into());
}
}
}
/// One file found by `search`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SearchRecord {
pub user: String,
pub path: String,
pub size: u64,
pub bitrate: Option<u32>,
pub duration: Option<u32>,
pub free_slot: bool,
pub speed: u32,
}
impl Record for SearchRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}\t{}",
sanitize(&self.user),
self.size,
self.bitrate
.map_or_else(|| "-".to_string(), |b| b.to_string()),
sanitize(&self.path)
)
}
}
/// One file in a peer's share listing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrowseRecord {
pub user: String,
pub directory: String,
pub path: String,
pub size: u64,
}
impl Record for BrowseRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}",
sanitize(&self.user),
self.size,
sanitize(&self.path)
)
}
}
/// The outcome of one transfer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DownloadRecord {
pub user: String,
pub path: String,
pub size: u64,
pub file: String,
}
impl Record for DownloadRecord {
fn text(&self) -> String {
sanitize(&self.file)
}
}
/// One public chat room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoomRecord {
pub room: String,
pub users: u32,
}
impl Record for RoomRecord {
fn text(&self) -> String {
format!("{}\t{}", self.users, sanitize(&self.room))
}
}
/// Something that happened in a room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoomEventRecord {
#[serde(rename = "type")]
pub kind: &'static str,
pub room: String,
pub user: String,
pub message: String,
}
impl Record for RoomEventRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}\t{}",
self.kind,
sanitize(&self.room),
sanitize(&self.user),
sanitize(&self.message)
)
}
}
/// One incoming private message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PrivateMessageRecord {
pub timestamp: u32,
pub user: String,
pub message: String,
}
impl Record for PrivateMessageRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}",
self.timestamp,
sanitize(&self.user),
sanitize(&self.message)
)
}
}
/// The result of probing automatic port mapping.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortmapRecord {
pub ok: bool,
pub backend: String,
pub external: Option<String>,
pub port: u16,
}
impl Record for PortmapRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}",
if self.ok { "ok" } else { "failed" },
sanitize(&self.backend),
self.external.as_deref().unwrap_or("-")
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn search_record() -> SearchRecord {
SearchRecord {
user: "bob".into(),
path: "@@dir\\01 - Track.mp3".into(),
size: 4096,
bitrate: Some(320),
duration: Some(210),
free_slot: true,
speed: 1000,
}
}
#[test]
fn exit_codes_are_stable() {
assert_eq!(Exit::Ok.code(), 0);
assert_eq!(Exit::Failure.code(), 1);
assert_eq!(Exit::Usage.code(), 2);
assert_eq!(Exit::Connection.code(), 3);
assert_eq!(Exit::NoResults.code(), 4);
assert_eq!(Exit::Timeout.code(), 5);
assert_eq!(Exit::Transfer.code(), 6);
}
#[test]
fn sanitize_keeps_records_on_one_line_with_stable_columns() {
assert_eq!(sanitize("a\tb\nc\r\nd"), "a b c d");
assert_eq!(
sanitize("plain (name) [2024].mp3"),
"plain (name) [2024].mp3"
);
}
#[test]
fn search_text_is_user_size_bitrate_path() {
let text = search_record().text();
let fields: Vec<&str> = text.split('\t').collect();
assert_eq!(fields, ["bob", "4096", "320", "@@dir\\01 - Track.mp3"]);
}
#[test]
fn unknown_bitrate_renders_as_a_dash() {
let record = SearchRecord {
bitrate: None,
..search_record()
};
assert_eq!(record.text().split('\t').nth(2), Some("-"));
}
#[test]
fn search_json_carries_every_field() {
let json = serde_json::to_value(search_record()).unwrap();
assert_eq!(json["user"], "bob");
assert_eq!(json["size"], 4096);
assert_eq!(json["bitrate"], 320);
assert_eq!(json["duration"], 210);
assert_eq!(json["free_slot"], true);
assert_eq!(json["speed"], 1000);
assert_eq!(json["path"], "@@dir\\01 - Track.mp3");
}
#[test]
fn a_tab_in_a_filename_cannot_forge_a_column() {
let record = SearchRecord {
path: "evil\tname.mp3".into(),
..search_record()
};
assert_eq!(record.text().split('\t').count(), 4);
}
#[test]
fn browse_text_is_user_size_path() {
let record = BrowseRecord {
user: "bob".into(),
directory: "@@music".into(),
path: "@@music\\a.flac".into(),
size: 12,
};
assert_eq!(record.text(), "bob\t12\t@@music\\a.flac");
}
#[test]
fn download_text_is_just_the_local_path() {
let record = DownloadRecord {
user: "bob".into(),
path: "@@music\\a.flac".into(),
size: 12,
file: "/music/a.flac".into(),
};
assert_eq!(record.text(), "/music/a.flac");
}
#[test]
fn room_text_leads_with_the_user_count() {
let record = RoomRecord {
room: "indie rock".into(),
users: 42,
};
assert_eq!(record.text(), "42\tindie rock");
}
#[test]
fn room_event_type_is_first_and_serializes_as_type() {
let record = RoomEventRecord {
kind: "message",
room: "lobby".into(),
user: "bob".into(),
message: "hi".into(),
};
assert_eq!(record.text(), "message\tlobby\tbob\thi");
let json = serde_json::to_value(&record).unwrap();
assert_eq!(json["type"], "message");
}
#[test]
fn portmap_text_reports_status_backend_and_address() {
let ok = PortmapRecord {
ok: true,
backend: "upnp".into(),
external: Some("1.2.3.4:2234".into()),
port: 2234,
};
assert_eq!(ok.text(), "ok\tupnp\t1.2.3.4:2234");
let failed = PortmapRecord {
ok: false,
backend: "none".into(),
external: None,
port: 2234,
};
assert_eq!(failed.text(), "failed\tnone\t-");
}
}
+62 -22
View File
@@ -78,9 +78,9 @@ pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10;
/// Layer CLI/env values over the config file over defaults.
///
/// The `--disable-listener` flag can only enable the disable (a bare flag
/// has no "explicitly off" form), so file `disable_listener = true` wins
/// unless the flag is passed.
/// The listener has an explicit form in both directions: `--listener` beats
/// `--no-listener` (and `SOULSEEK_NO_LISTENER`), which beats the file's
/// `disable_listener`, so a config file can always be overridden per run.
#[must_use]
pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
let download_dir = cli
@@ -99,8 +99,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
.listener_port
.or(file.listener_port)
.unwrap_or(DEFAULT_LISTENER_PORT),
disable_listener: cli.disable_listener
|| file.disable_listener.unwrap_or(false),
disable_listener: if cli.listener {
false
} else {
cli.no_listener || file.disable_listener.unwrap_or(false)
},
download_dir: download_dir.clone(),
shared_dirs: resolve_shared_dirs(cli, file, &download_dir),
max_concurrent_downloads: cli
@@ -111,7 +114,10 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
.search_timeout
.or(file.search_timeout)
.unwrap_or(DEFAULT_SEARCH_TIMEOUT),
password_cmd: file.password_cmd.clone(),
password_cmd: cli
.password_cmd
.clone()
.or_else(|| file.password_cmd.clone()),
}
}
@@ -125,7 +131,7 @@ fn resolve_shared_dirs(
file: &FileConfig,
download_dir: &str,
) -> Vec<String> {
if cli.shared_dir.is_none()
if cli.shared_dir.is_empty()
&& file.shared_dir.is_none()
&& file.shared_dirs.is_none()
{
@@ -148,20 +154,7 @@ mod tests {
use crate::cli::Cli;
fn bare_cli() -> Cli {
Cli {
username: None,
password: None,
server: None,
disable_listener: false,
listener_port: None,
verbose: 0,
log_file: None,
command: None,
download_dir: None,
shared_dir: None,
max_concurrent_downloads: None,
search_timeout: None,
}
Cli::default()
}
#[test]
@@ -244,7 +237,7 @@ mod tests {
#[test]
fn cli_shared_dir_combines_with_file_list_and_dedupes() {
let mut cli = bare_cli();
cli.shared_dir = Some("/a".into());
cli.shared_dir = vec!["/a".into()];
let file = FileConfig {
shared_dirs: Some(vec!["/a".into(), "/b".into()]),
..FileConfig::default()
@@ -256,6 +249,53 @@ mod tests {
);
}
#[test]
fn repeated_cli_shared_dirs_are_all_kept() {
let mut cli = bare_cli();
cli.shared_dir = vec!["/a".into(), "/b".into()];
let resolved = resolve(&cli, &FileConfig::default());
assert_eq!(
resolved.shared_dirs,
vec!["/a".to_string(), "/b".to_string()]
);
}
#[test]
fn an_empty_cli_shared_dir_disables_sharing() {
let mut cli = bare_cli();
cli.shared_dir = vec![String::new()];
let resolved = resolve(&cli, &FileConfig::default());
assert!(resolved.shared_dirs.is_empty());
}
#[test]
fn no_listener_flag_disables_the_listener() {
let mut cli = bare_cli();
cli.no_listener = true;
assert!(resolve(&cli, &FileConfig::default()).disable_listener);
}
#[test]
fn listener_flag_overrides_the_config_file() {
let file = FileConfig {
disable_listener: Some(true),
..FileConfig::default()
};
assert!(resolve(&bare_cli(), &file).disable_listener);
let mut cli = bare_cli();
cli.listener = true;
assert!(!resolve(&cli, &file).disable_listener);
}
#[test]
fn listener_flag_wins_over_no_listener() {
let mut cli = bare_cli();
cli.listener = true;
cli.no_listener = true;
assert!(!resolve(&cli, &FileConfig::default()).disable_listener);
}
#[test]
fn file_values_override_defaults() {
let file = FileConfig {
+53 -27
View File
@@ -61,43 +61,69 @@ impl Drop for PortMapper {
}
}
/// The outcome of a port-mapping probe: a verdict a script can branch on
/// plus the advice a human needs when it fails.
pub struct Probe {
pub ok: bool,
/// `upnp`, `natpmp`, or `none`.
pub backend: &'static str,
/// External `address:port` the mapping exposes, when the backend reports
/// one.
pub external: Option<String>,
pub advice: String,
}
/// Synchronously probe whether automatic port mapping works on this network:
/// try to map `port` (UPnP then NAT-PMP), report the result in human-readable
/// form, then remove the probe mapping. Used by the `portmap` CLI command so a
/// user can verify reachability on their actual router.
/// try to map `port` (UPnP then NAT-PMP), then remove the probe mapping.
/// Backs the `portmap` command so a user can verify reachability on their
/// actual router.
#[must_use]
pub fn diagnose(port: u16) -> String {
pub fn probe(port: u16) -> Probe {
let Some(local_ip) = local_lan_ip() else {
return "Could not determine this machine's LAN IP — are you connected \
to a network?"
.to_string();
return Probe {
ok: false,
backend: "none",
external: None,
advice: "Could not determine this machine's LAN IP — are you \
connected to a network?"
.to_string(),
};
};
match map_once(local_ip, port) {
Some((backend, _lease)) => {
let detail = match &backend {
Backend::Upnp { gateway, .. } => {
let ip = gateway.get_external_ip().map_or_else(
|_| "unknown".to_string(),
|ip| ip.to_string(),
);
format!("UPnP-IGD (external address {ip}:{port})")
}
Backend::NatPmp { .. } => "NAT-PMP".to_string(),
let (name, external) = match &backend {
Backend::Upnp { gateway, .. } => (
"upnp",
gateway
.get_external_ip()
.ok()
.map(|ip| format!("{ip}:{port}")),
),
Backend::NatPmp { .. } => ("natpmp", None),
};
remove(&backend); // clean up the probe mapping
format!(
"✅ Automatic port mapping works via {detail}.\n TCP {port} \
was opened on your router, so firewalled peers and the server \
can reach you. The app does this automatically while it runs."
)
Probe {
ok: true,
backend: name,
external,
advice: format!(
"Automatic port mapping works. TCP {port} was opened on \
your router, so firewalled peers and the server can \
reach you. The app does this automatically while it runs."
),
}
}
None => format!(
"❌ No UPnP/NAT-PMP router responded for TCP {port}.\n Automatic \
mapping won't work on this network. To browse/download firewalled \
peers, forward TCP {port} to this machine on your router (or \
enable UPnP there)."
),
None => Probe {
ok: false,
backend: "none",
external: None,
advice: format!(
"No UPnP/NAT-PMP router responded for TCP {port}. To \
browse/download firewalled peers, forward TCP {port} to this \
machine on your router (or enable UPnP there)."
),
},
}
}
-636
View File
@@ -1,636 +0,0 @@
use crate::models::FileDisplayData;
use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, format_bytes, format_shortcuts_styled,
get_bitrate, get_spinner_char, header_style, highlight_style, pane_block,
plain_title, primary_style, success_style, warning_style,
};
use color_eyre::Result;
use ratatui::text::{Line, Span};
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
layout::{Alignment, Constraint, Layout},
style::Style,
widgets::{
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
TableState, Wrap,
},
};
use soulseek_rs::Client;
use std::{
collections::HashSet,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
pub struct FileSelector {
all_items: Vec<FileDisplayData>,
items: Vec<FileDisplayData>,
filtered_indices: Vec<usize>,
state: TableState,
should_exit: bool,
selected_indices: HashSet<usize>,
filter_query: String,
is_filtering: bool,
client: Option<Arc<Client>>,
soulseek_query: String,
search_timeout: Duration,
search_start_time: Instant,
search_cancel_flag: Arc<AtomicBool>,
search_active: bool,
spinner_state: usize,
last_spinner_update: Instant,
last_result_count: usize,
}
impl FileSelector {
pub fn new_with_live_search(
client: Arc<Client>,
query: String,
timeout: Duration,
cancel_flag: Arc<AtomicBool>,
) -> Self {
let mut state = TableState::default();
state.select(Some(0));
Self {
all_items: Vec::new(),
items: Vec::new(),
filtered_indices: Vec::new(),
state,
should_exit: false,
selected_indices: HashSet::new(),
filter_query: String::new(),
is_filtering: false,
client: Some(client),
soulseek_query: query,
search_timeout: timeout,
search_start_time: Instant::now(),
search_cancel_flag: cancel_flag,
search_active: true,
spinner_state: 0,
last_spinner_update: Instant::now(),
last_result_count: 0,
}
}
pub fn run(
&mut self,
mut terminal: DefaultTerminal,
) -> Result<(DefaultTerminal, Vec<usize>)> {
while !self.should_exit {
terminal.draw(|frame| self.render(frame))?;
self.spinner_state = (self.spinner_state + 1) % 10;
self.last_spinner_update = Instant::now();
// Poll for new search results if active
if self.search_active {
if let Some(ref client) = self.client {
let current_count =
client.get_search_results_count(&self.soulseek_query);
if current_count != self.last_result_count {
self.update_results_from_client();
self.last_result_count = current_count;
}
}
// Check if search timeout reached
if self.search_start_time.elapsed() >= self.search_timeout {
self.search_active = false;
self.search_cancel_flag.store(true, Ordering::Relaxed);
}
}
let timeout = if self.search_active {
Duration::from_millis(50)
} else {
Duration::from_millis(100)
};
if poll(timeout)?
&& let Event::Key(key) = event::read()?
{
self.handle_key(key);
}
}
Ok((terminal, self.selected_indices.iter().copied().collect()))
}
fn update_results_from_client(&mut self) {
if let Some(ref client) = self.client {
let search_results =
client.get_search_results(&self.soulseek_query);
// Convert search results to FileDisplayData
let mut new_items = Vec::new();
for result in &search_results {
for file in &result.files {
new_items.push(FileDisplayData {
filename: file.name.clone(),
size: file.size,
username: result.username.clone(),
speed: result.speed,
slots: result.slots,
bitrate: get_bitrate(&file.attribs),
length_seconds: file.attribs.get(&1).copied(),
});
}
}
let len = new_items.len();
self.all_items = new_items.clone();
self.items = new_items;
self.filtered_indices = (0..len).collect();
// Keep selection valid or set to 0
if self.state.selected().is_none() && !self.items.is_empty() {
self.state.select(Some(0));
}
}
}
fn toggle_selection(&mut self) {
if let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) = self.filtered_indices.get(filtered_idx)
{
if self.selected_indices.contains(&original_idx) {
self.selected_indices.remove(&original_idx);
} else {
self.selected_indices.insert(original_idx);
}
}
}
fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if self.is_filtering {
match key.code {
KeyCode::Char(' ') => {
self.toggle_selection();
}
KeyCode::Char(c) => {
self.filter_query.push(c);
self.apply_filter();
}
KeyCode::Backspace => {
self.filter_query.pop();
self.apply_filter();
}
KeyCode::Esc => {
self.filter_query.clear();
self.apply_filter();
self.is_filtering = false;
}
KeyCode::Enter => {
// If nothing selected, select the current item under cursor
if self.selected_indices.is_empty()
&& let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) =
self.filtered_indices.get(filtered_idx)
{
self.selected_indices.insert(original_idx);
}
self.should_exit = true;
}
KeyCode::Up | KeyCode::Down => {
if key.code == KeyCode::Up {
self.select_previous();
} else {
self.select_next();
}
}
_ => {}
}
} else {
match key.code {
KeyCode::Char(' ') => {
self.toggle_selection();
}
KeyCode::Char('/') => {
self.is_filtering = true;
}
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::Char('a') => {
// Select all visible/filtered items
for &original_idx in &self.filtered_indices {
self.selected_indices.insert(original_idx);
}
}
KeyCode::Char('A') => {
// Deselect all items
self.selected_indices.clear();
}
KeyCode::Enter => {
// If nothing selected, select the current item under cursor
if self.selected_indices.is_empty()
&& let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) =
self.filtered_indices.get(filtered_idx)
{
self.selected_indices.insert(original_idx);
}
self.should_exit = true;
}
KeyCode::Esc | KeyCode::Char('q') => {
self.selected_indices.clear();
self.should_exit = true;
}
_ => {}
}
}
}
/// Previous index with wrap-around, or None when the list is empty.
const fn wrapping_prev(
selected: Option<usize>,
len: usize,
) -> Option<usize> {
if len == 0 {
return None;
}
Some(match selected {
Some(i) if i > 0 => i - 1,
_ => len - 1,
})
}
/// Next index with wrap-around, or None when the list is empty.
const fn wrapping_next(
selected: Option<usize>,
len: usize,
) -> Option<usize> {
if len == 0 {
return None;
}
Some(match selected {
Some(i) if i < len - 1 => i + 1,
_ => 0,
})
}
const fn select_previous(&mut self) {
let i = Self::wrapping_prev(self.state.selected(), self.items.len());
self.state.select(i);
}
const fn select_next(&mut self) {
let i = Self::wrapping_next(self.state.selected(), self.items.len());
self.state.select(i);
}
fn select_first(&mut self) {
self.state.select((!self.items.is_empty()).then_some(0));
}
const fn select_last(&mut self) {
self.state.select(self.items.len().checked_sub(1));
}
fn apply_filter(&mut self) {
let query_lower = self.filter_query.to_lowercase();
if query_lower.is_empty() {
self.items = self.all_items.clone();
self.filtered_indices = (0..self.all_items.len()).collect();
} else {
self.items.clear();
self.filtered_indices.clear();
for (i, item) in self.all_items.iter().enumerate() {
if item.filename.to_lowercase().contains(&query_lower)
|| item.username.to_lowercase().contains(&query_lower)
{
self.items.push(item.clone());
self.filtered_indices.push(i);
}
}
}
if self.items.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
// Always split layout into table, optional info, and controls footer
let has_info = self.is_filtering
|| !self.selected_indices.is_empty()
|| !self.filter_query.is_empty();
let chunks = if has_info {
Layout::vertical([
Constraint::Min(0),
Constraint::Length(3),
Constraint::Length(3),
])
.split(area)
} else {
Layout::vertical([Constraint::Min(0), Constraint::Length(3)])
.split(area)
};
let table_area = chunks[0];
let (info_area, controls_area) = if has_info {
(Some(chunks[1]), chunks[2])
} else {
(None, chunks[1])
};
self.render_results_table(frame, table_area);
if self.items.is_empty() {
self.render_loading_message(frame, table_area);
}
if let Some(info_area) = info_area {
self.render_info(frame, info_area);
}
self.render_controls(frame, controls_area);
}
fn table_title(&self) -> String {
if self.search_active {
// Live Soulseek search is active
let spinner = get_spinner_char(self.spinner_state);
let elapsed = self.search_start_time.elapsed().as_secs();
let total = self.search_timeout.as_secs();
format!(
"{} Searching: '{}' - {} results ({}/{}s)",
spinner,
self.soulseek_query,
self.all_items.len(),
elapsed,
total
)
} else if self.is_filtering {
format!(
"Multi-select files to download ({}/{} matches)",
self.items.len(),
self.all_items.len()
)
} else {
format!(
"Multi-select files to download ({} selected)",
self.selected_indices.len()
)
}
}
fn render_results_table(
&mut self,
frame: &mut Frame,
table_area: ratatui::layout::Rect,
) {
let title = self.table_title();
let header = Row::new(vec![
Cell::from(""),
Cell::from("Filename"),
Cell::from("Size"),
Cell::from("Username"),
Cell::from("Speed"),
Cell::from("Slots"),
Cell::from("Bitrate"),
])
.style(header_style())
.height(1);
let rows: Vec<Row> = self
.items
.iter()
.enumerate()
.map(|(filtered_idx, item)| {
let original_idx = self.filtered_indices[filtered_idx];
let is_selected = self.selected_indices.contains(&original_idx);
let checkbox = if is_selected { "[✓]" } else { "[ ]" };
let speed_mb = f64::from(item.speed) / BYTES_PER_MB;
let speed_str = if speed_mb > 0.0 {
format!("{speed_mb:.1} MB/s")
} else {
"-".to_string()
};
let slots_str = format!("{}", item.slots);
let bitrate_str = match item.bitrate {
Some(br) => format!("{br} kbps"),
None => "-".to_string(),
};
let cells = vec![
Cell::from(checkbox),
Cell::from(item.filename.clone()),
Cell::from(format_bytes(item.size)),
Cell::from(item.username.clone()),
Cell::from(speed_str),
Cell::from(slots_str),
Cell::from(bitrate_str),
];
let style = if is_selected {
success_style()
} else {
Style::default()
};
Row::new(cells).style(style).height(1)
})
.collect();
let widths = [
Constraint::Length(5),
Constraint::Min(30),
Constraint::Length(12),
Constraint::Length(15),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Length(10),
];
let table = Table::new(rows, widths)
.header(header)
.block(pane_block(true).title(title))
.column_spacing(1)
.row_highlight_style(highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(
table,
table_area,
frame.buffer_mut(),
&mut self.state,
);
}
fn render_loading_message(
&self,
frame: &mut Frame,
table_area: ratatui::layout::Rect,
) {
let spinner = get_spinner_char(self.spinner_state);
let mut loading_message: Vec<Span> = Vec::new();
if self.items.is_empty() && self.search_active {
loading_message.extend(vec![
Span::raw(spinner),
Span::raw(" Searching for '"),
Span::styled(self.soulseek_query.clone(), primary_style()),
Span::raw(format!(
"' [{}/{}s]",
self.search_start_time.elapsed().as_secs(),
self.search_timeout.as_secs()
)),
]);
} else {
loading_message.extend(vec![
Span::raw(spinner),
Span::raw(" Searching for '"),
Span::styled(self.soulseek_query.clone(), primary_style()),
Span::raw("'; No results yet"),
]);
}
// Center the loading message
let vertical = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Fill(1),
])
.split(table_area);
let loading_message_line = Line::from(loading_message);
// Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen
let text_width =
loading_message_line.to_string().chars().count() as u16 + 5; // +10 for borders, padding, and safety margin
let max_width = (table_area.width * 80) / 100;
let widget_width = text_width.min(max_width);
let horizontal = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(widget_width),
Constraint::Fill(1),
])
.split(vertical[1]);
let loading_widget = Paragraph::new(loading_message_line)
.style(Style::default())
.block(pane_block(false))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(loading_widget, horizontal[1]);
}
fn render_info(&self, frame: &mut Frame, info_area: ratatui::layout::Rect) {
let (info_text, title, style) = if self.is_filtering {
(
format!("Filter: {}", self.filter_query),
"Filter",
warning_style(),
)
} else if !self.filter_query.is_empty() {
(
format!(
"Current filter: {} (press / to modify, Esc to clear)",
self.filter_query
),
"Filter",
primary_style(),
)
} else {
(
format!(
"{} file(s) selected for download",
self.selected_indices.len()
),
"Selection",
success_style(),
)
};
let info_widget = Paragraph::new(info_text)
.block(pane_block(false).title(plain_title(title, false)))
.style(style);
frame.render_widget(info_widget, info_area);
}
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
let controls_line = if self.is_filtering {
format_shortcuts_styled(&[
("Space", "toggle"),
("a", "select-all"),
("A", "deselect-all"),
("Enter", "download"),
("Esc", "exit filter"),
])
} else if self.search_active {
format_shortcuts_styled(&[
("Space", "toggle"),
("a", "select-all"),
("A", "deselect-all"),
("Enter", "download"),
("Esc/q", "cancel"),
])
} else {
format_shortcuts_styled(&[
("Space", "toggle"),
("a", "select-all"),
("A", "deselect-all"),
("Enter", "download"),
("Esc/q", "cancel"),
("/", "filter"),
])
};
let controls_widget = Paragraph::new(controls_line)
.block(pane_block(false).title(plain_title("Controls", false)));
frame.render_widget(controls_widget, area);
}
}
#[cfg(test)]
mod tests {
use super::FileSelector;
// Navigating an empty list (e.g. a filter that matched nothing) must not
// underflow `len - 1`; it should yield no selection.
#[test]
fn wrapping_prev_on_empty_list_is_none() {
assert_eq!(FileSelector::wrapping_prev(None, 0), None);
assert_eq!(FileSelector::wrapping_prev(Some(0), 0), None);
}
#[test]
fn wrapping_next_on_empty_list_is_none() {
assert_eq!(FileSelector::wrapping_next(None, 0), None);
assert_eq!(FileSelector::wrapping_next(Some(0), 0), None);
}
#[test]
fn wrapping_navigation_wraps_around() {
assert_eq!(FileSelector::wrapping_prev(Some(0), 3), Some(2));
assert_eq!(FileSelector::wrapping_prev(Some(2), 3), Some(1));
assert_eq!(FileSelector::wrapping_next(Some(2), 3), Some(0));
assert_eq!(FileSelector::wrapping_next(Some(1), 3), Some(2));
}
}
+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};
+1 -3
View File
@@ -1,4 +1,3 @@
mod download_selector;
mod downloads;
pub mod login;
mod main_tui;
@@ -6,8 +5,7 @@ mod panes;
mod styles;
mod utils;
pub use download_selector::FileSelector;
pub use downloads::{render_download_stats, show_multi_download_progress};
pub use downloads::render_download_stats;
pub use main_tui::launch_main_tui;
pub use styles::*;
pub use utils::*;
-6
View File
@@ -30,12 +30,6 @@ pub fn format_speed(speed_bytes_per_sec: f64) -> String {
format!("{mb:.1} MB/s")
}
pub fn get_bitrate(
attribs: &std::collections::HashMap<u32, u32>,
) -> Option<u32> {
attribs.get(&0).copied()
}
const SPINNER_CHARS: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];