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

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

The commands are now built for scripts and agents:

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

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

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

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

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

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent c2dd227cee
commit be6581ee6c
17 changed files with 3756 additions and 1583 deletions
+6 -1
View File
@@ -98,7 +98,12 @@ jobs:
test -x soulfind/bin/soulfind
echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV"
- name: Run e2e tests (required against soulfind)
- name: Run library e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture
- name: Run CLI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
+179 -26
View File
@@ -21,8 +21,9 @@ music enthusiasts around the world to share niche music.
connections when a peer can't be reached directly
- **Automatic port mapping** — opens your listen port via UPnP-IGD and
NAT-PMP, with a `portmap` subcommand to test your router
- **TUI and CLI** — a full terminal interface, plus scriptable subcommands
(`search`, `message`, `browse`, `rooms`, `chat`, `portmap`)
- **TUI and CLI** — a full terminal interface, plus a one-shot command surface
built for scripts and agents: every feature is reachable without a terminal,
stdout carries records only, and the exit code says what happened
## Project Goals
@@ -87,16 +88,158 @@ soulseek-rs-lib = "6"
## Usage
Run it with no subcommand for the interactive TUI:
```bash
./target/release/soulseek-rs "the weeknd Blinding Lights"
soulseek-rs
```
Everything else is a one-shot command that runs headless.
### Scripting
The one-shot commands follow three rules, so they compose with other tools:
- **stdout is data.** One record per line, and nothing else. Progress,
warnings, and errors go to stderr; `--quiet` silences the progress.
- **`--json` emits newline-delimited JSON**, one object per line. Without it,
records are tab-separated fields with no header and no decoration.
- **The exit code is the verdict.** No output plus exit 0 never means failure.
| Code | Meaning |
| ---- | ------- |
| 0 | success |
| 1 | unexpected error |
| 2 | bad arguments, missing credentials, unusable configuration |
| 3 | could not reach the server, or the login was rejected |
| 4 | the command worked but found nothing |
| 5 | timed out waiting for a response or a transfer |
| 6 | a transfer started but did not finish |
#### Commands
```bash
soulseek-rs search <QUERY> # print matching files
soulseek-rs download <USER> <PATH> # fetch one known file
soulseek-rs download --stdin # fetch files listed on stdin
soulseek-rs get <QUERY> # search, pick, and download
soulseek-rs browse <USER> # list a user's shared files
soulseek-rs room list # public rooms and user counts
soulseek-rs room say <ROOM> <MESSAGE> # post to a room
soulseek-rs room listen <ROOM> # stream room messages and joins
soulseek-rs message send <USER> <TEXT> # send a private message
soulseek-rs message read # stream incoming private messages
soulseek-rs portmap # test automatic port mapping
```
Every command emits records except the two that perform an action —
`room say` and `message send` say nothing and answer with their exit code
alone. `room listen` and `message read` stream records as they arrive, until
`--duration` seconds pass or, with `--follow`, until they are interrupted.
#### Record shapes
Text mode puts the identifying fields first and the remote path last, because
a path may contain anything except a tab (tabs and newlines in peer-supplied
text are replaced with spaces so a record is always exactly one line):
```
search user size bitrate path
browse user size path
room users room
download local-path
```
`download --stdin` reads back either shape it emits — a JSON object per line,
or tab-separated text whose first field is the user and last field is the
path — so `search` and `browse` pipe straight into it.
#### Recipes
```bash
# Everything above 320kbps from peers with a free slot, best first
soulseek-rs search "aphex twin xtal" --min-bitrate 320 --free-slots
# One command, start to finish: search, pick the best, download it
soulseek-rs get "aphex twin xtal"
# Filter with jq, then fetch what survived
soulseek-rs search "rjd2" --json \
| jq -c 'select(.size > 5000000 and .free_slot)' \
| soulseek-rs download --stdin
# Grab a peer's whole FLAC folder
soulseek-rs browse someuser | grep -i '\.flac$' | soulseek-rs download --stdin
# Branch on the outcome
if soulseek-rs get "some rare track" --quiet; then
echo "got it"
elif [ $? -eq 4 ]; then
echo "nobody has it"
fi
# Log room chat as JSON until interrupted
soulseek-rs room listen lobby --follow --json >> lobby.ndjson
```
Downloads run under a deadline (`--timeout`, 300s by default) and
`--max-concurrent-downloads` at a time, so an unattended run always ends.
### Configuration
Every setting can come from a flag, an environment variable, or the config
file, in that order of precedence. Flags work before or after the subcommand.
| Flag | Environment variable | `config.toml` key |
| ---- | -------------------- | ----------------- |
| `--username` | `SOULSEEK_USERNAME` | `username` |
| `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) |
| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` |
| `--server` | `SOULSEEK_SERVER` | `server` |
| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` |
| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` |
| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` |
| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` |
| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` |
| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` |
| `--log-file` | `SOULSEEK_LOG_FILE` | — |
Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`.
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different config file and
`--no-config` ignores it entirely, which is what an isolated or containerised
run wants. The config file lives at `~/.config/soulseek-rs/config.toml` on
macOS and Linux; `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the
config and state directories wholesale.
#### Credentials for unattended runs
Four ways to supply a password, in the order they are consulted:
1. `--password-stdin` — read the first line of stdin, like `docker login`.
2. `--password` / `SOULSEEK_PASSWORD` — convenient, but visible in `ps`.
3. The **OS keychain** (macOS Keychain, Windows Credential Manager, Linux
Secret Service), under service `soulseek-rs` and your username. The TUI
stores your password here after a successful login.
4. `password_cmd` / `--password-cmd` — a shell command whose stdout is the
password, e.g. `pass show soulseek`. Best for headless boxes with no
keychain.
```bash
# Nothing sensitive on the command line or in the environment
pass show soulseek | soulseek-rs --username alice --password-stdin get "some track"
```
Logging stays at errors only unless you ask for more: `-v` through `-vvvv`
raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given.
`NO_COLOR` and a non-terminal stderr both disable colour.
### Private messages
Send a private message to another user from the command line:
Send and read private messages from the command line:
```bash
soulseek-rs message <username> "hello there"
soulseek-rs message send <username> "hello there"
soulseek-rs message read --duration 60 # or --follow to run until killed
```
In the interactive TUI:
@@ -111,9 +254,9 @@ In the interactive TUI:
From the command line:
```bash
soulseek-rs rooms # list public rooms with user counts
soulseek-rs chat <room> # join and print messages for a while
soulseek-rs chat <room> "hello room" # join, say one message, and exit
soulseek-rs room list # public rooms with user counts
soulseek-rs room listen <room> # stream messages, joins, and leaves
soulseek-rs room say <room> "hello room" # post one message and exit
```
In the interactive TUI, press `c` to open the chat-rooms popup:
@@ -137,21 +280,25 @@ and **NAT-PMP**, so firewalled peers and the server can connect back to you.
This is best-effort: if your router has UPnP/NAT-PMP disabled it's a no-op and
you'll see a log line suggesting you forward the port manually.
- The mapped/forwarded port is your `--listener-port` (env `LISTENER_PORT`,
default `2234`); it is renewed automatically and removed on exit.
- The mapped/forwarded port is your `--listener-port` (env
`SOULSEEK_LISTENER_PORT`, default `2234`); it is renewed automatically and
removed on exit.
- If auto-mapping can't get that exact port, forward **TCP 2234** (or whatever
`--listener-port` you chose) to this machine on your router.
- Pass `--disable-listener` to turn the listener (and port mapping) off.
- Pass `--no-listener` to turn the listener (and port mapping) off, or
`--listener` to force it on when the config file disables it.
Check whether it works on **your** network without launching the whole client:
```bash
soulseek-rs portmap
soulseek-rs portmap # exits 0 when mapping works, 4 when it does not
soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"}
```
It tries to open the port via UPnP/NAT-PMP and prints whether your router
allowed it (and your external address), then removes the test mapping. If it
reports failure, enable UPnP on your router or forward the port manually.
It tries to open the port via UPnP/NAT-PMP, reports whether your router allowed
it (and your external address), then removes the test mapping. Because the
verdict is in the exit code, `soulseek-rs portmap || notify-me` works as a
health check.
If both you and a peer are behind routers with no forwarded port, browsing that
peer can't work — that's a fundamental Soulseek/peer-to-peer limitation, not a
@@ -185,10 +332,13 @@ cargo fmt
### End-to-end tests
The `soulseek-rs-lib/tests/e2e.rs` suite exercises the client against a real
Soulseek server using [soulfind](https://github.com/soulfind-dev/soulfind), a
local server implementation. The tests are **server-optional**: they run when a
server is available and otherwise skip (so `cargo test` stays green everywhere).
Two suites exercise the client against a real Soulseek server using
[soulfind](https://github.com/soulfind-dev/soulfind), a local server
implementation: `soulseek-rs-lib/tests/e2e.rs` covers the protocol library, and
`soulseek-rs/tests/cli_e2e.rs` drives the actual binary the way a script would
(records on stdout, exit codes, real searches, downloads, and chat). The tests
are **server-optional**: they run when a server is available and otherwise skip
(so `cargo test` stays green everywhere).
They locate a server in this order:
@@ -200,12 +350,14 @@ They locate a server in this order:
```bash
# Build soulfind once (see its BUILDING.md), then:
SOULFIND_BIN=/path/to/soulfind/bin/soulfind \
cargo test -p soulseek-rs-lib --test e2e -- --nocapture
cargo test --workspace -- --nocapture
```
> On macOS, soulfind's `SQLITE_CONFIG_SINGLETHREAD` optimization is rejected by
> the system SQLite; building soulfind with that call made non-fatal lets it run
> locally. It is only a mutex optimization and does not affect the server.
> On macOS, soulfind's `sqlite3_config` call is rejected by the system SQLite
> and the server dies at startup with `SQLite error 7 (out of memory)` — which
> the suite reports as a skip. Run the tests with
> `DYLD_LIBRARY_PATH=/opt/homebrew/opt/sqlite/lib` so soulfind picks up
> Homebrew's SQLite instead; no patching needed.
Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure
instead of a skip. Continuous integration sets it so the e2e suite genuinely
@@ -217,9 +369,10 @@ runs against a freshly built soulfind rather than silently skipping.
- **Format & Clippy** — `cargo fmt --all --check` and
`cargo clippy --workspace --all-targets -- -D warnings`.
- **Test** — builds soulfind from source (LDC + `dub build :server`), points
`SOULFIND_BIN` at it, and runs `cargo test --workspace` with
`SOULSEEK_E2E_REQUIRED=1`, so unit and end-to-end tests both run.
- **Test** — `cargo test --verbose` on Linux, macOS, and Windows.
- **End-to-end** — builds soulfind from source (LDC + `dub build :server`),
points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites
with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
## License
+13 -1
View File
@@ -5,6 +5,11 @@ use super::{
TcpStream, debug, error, info, mpsc, thread, trace,
};
/// Ceiling on the wait for a login verdict. Generous enough for a slow server
/// and a retrying connect, short enough that an unattended caller ends.
const LOGIN_RESPONSE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(45);
impl Client {
pub fn connect(&mut self) -> Result<()> {
let (sender, message_reader): (
@@ -89,6 +94,13 @@ impl Client {
Ok(())
}
/// Log in and wait for the server's verdict.
///
/// The wait is bounded: the actor only answers once it has a working
/// connection, so a server that never completes the TCP handshake would
/// otherwise block the caller forever. A caller that hits
/// [`SoulseekRs::Timeout`] can retry or give up, but it always regains
/// control.
pub fn login(&self) -> Result<bool> {
info!("Logging in as {}", self.username);
if let Some(handle) = &self.server_handle {
@@ -99,7 +111,7 @@ impl Client {
response: tx,
});
match rx.recv() {
match rx.recv_timeout(LOGIN_RESPONSE_TIMEOUT) {
Ok(result) => result,
Err(_) => Err(SoulseekRs::Timeout),
}
+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>,
}
+161 -416
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,44 +61,70 @@ 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."
)
}
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)."
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 => 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)."
),
},
}
}
/// Which backend created the active mapping, carrying what's needed to renew and
-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] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+977
View File
@@ -0,0 +1,977 @@
//! End-to-end tests of the scriptable command surface, driving the real
//! binary the way a script would: arguments in, records on stdout, progress on
//! stderr, a verdict in the exit code.
//!
//! The tests split in two. The first half needs no server and always runs.
//! The second half talks to a real Soulseek server (soulfind) and follows the
//! same server-optional rule as the library suite: with `SOULFIND_BIN` (or a
//! sibling `soulfind/bin/soulfind` checkout) it spawns one, otherwise it skips
//! with a notice. `SOULSEEK_E2E_REQUIRED=1` turns a missing server into a
//! failure, which is how CI guarantees these actually run.
#![allow(clippy::doc_markdown)]
use soulseek_rs::{Client, ClientSettings, PeerAddress};
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_soulseek-rs");
/// Exit codes the command surface promises. Kept as literals so a change to
/// the enum has to be a deliberate change here too.
const EXIT_OK: i32 = 0;
const EXIT_USAGE: i32 = 2;
const EXIT_CONNECTION: i32 = 3;
const EXIT_NO_RESULTS: i32 = 4;
/// Every environment variable the CLI reads, cleared for each run so a
/// developer's shell (or a stray `.env`) cannot change what a test observes.
const CLI_ENV_VARS: [&str; 11] = [
"SOULSEEK_USERNAME",
"SOULSEEK_PASSWORD",
"SOULSEEK_PASSWORD_CMD",
"SOULSEEK_SERVER",
"SOULSEEK_NO_LISTENER",
"SOULSEEK_LISTENER_PORT",
"SOULSEEK_DOWNLOAD_DIR",
"SOULSEEK_SHARED_DIR",
"SOULSEEK_MAX_CONCURRENT_DOWNLOADS",
"SOULSEEK_SEARCH_TIMEOUT",
"SOULSEEK_CONFIG",
];
/// Build a command for the binary, isolated from the developer's environment
/// and from the repository's own working directory.
fn command(args: &[&str]) -> Command {
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.current_dir(std::env::temp_dir());
command.args(args);
command
}
fn run(args: &[&str]) -> Output {
command(args).output().expect("the binary should run")
}
fn code(output: &Output) -> i32 {
output
.status
.code()
.expect("the process should not be signalled")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
/// Records are one per line; blank trailing lines are not records.
fn records(output: &Output) -> Vec<String> {
stdout(output)
.lines()
.filter(|line| !line.trim().is_empty())
.map(String::from)
.collect()
}
// ---------------------------------------------------------------------------
// No server required
// ---------------------------------------------------------------------------
#[test]
fn help_lists_every_scriptable_command() {
let output = run(&["--help"]);
assert_eq!(code(&output), EXIT_OK);
let help = stdout(&output);
for command in [
"search", "download", "get", "browse", "room", "message", "portmap",
] {
assert!(help.contains(command), "--help should mention {command}");
}
}
#[test]
fn help_documents_the_exit_codes_a_script_branches_on() {
let help = stdout(&run(&["--help"]));
assert!(help.contains("Exit codes"), "help should list exit codes");
for code in ["2", "3", "4", "5", "6"] {
assert!(help.contains(code), "exit code {code} should be documented");
}
}
#[test]
fn an_unknown_flag_is_a_usage_error() {
let output = run(&["--definitely-not-a-flag"]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn missing_credentials_are_a_usage_error_with_a_pointer_to_the_fix() {
let output = run(&["--no-config", "search", "anything"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stderr(&output).contains("SOULSEEK_USERNAME"));
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn a_falsey_boolean_environment_variable_is_accepted() {
// `DISABLE_LISTENER=1` used to abort every invocation with a clap parse
// error, which made the binary unusable from a container.
for value in ["1", "0", "true", "false", "yes", ""] {
let output = command(&["--no-config", "search", "anything"])
.env("SOULSEEK_NO_LISTENER", value)
.output()
.expect("the binary should run");
assert!(
!stderr(&output).contains("invalid value"),
"SOULSEEK_NO_LISTENER={value:?} should be accepted"
);
}
}
#[test]
fn global_flags_are_accepted_after_the_subcommand() {
let output = run(&["search", "anything", "--no-config", "--json"]);
assert_eq!(
code(&output),
EXIT_USAGE,
"should reach the credential check, not a parse error"
);
assert!(!stderr(&output).contains("unexpected argument"));
}
#[test]
fn the_password_environment_value_is_not_echoed_in_help() {
let output = command(&["--help"])
.env("SOULSEEK_PASSWORD", "hunter2")
.output()
.expect("the binary should run");
assert!(
!stdout(&output).contains("hunter2"),
"--help must not print the password it inherited"
);
}
#[test]
fn download_without_a_target_is_a_usage_error() {
assert_eq!(code(&run(&["download", "--no-config"])), EXIT_USAGE);
assert_eq!(code(&run(&["download", "--no-config", "bob"])), EXIT_USAGE);
}
#[test]
fn download_stdin_cannot_be_combined_with_positional_arguments() {
let output = run(&["download", "--stdin", "bob", "file.mp3"]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn a_malformed_server_address_is_a_usage_error() {
let output = run(&[
"--no-config",
"--username",
"u",
"--password",
"p",
"--server",
"no-port-here",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn an_unreachable_server_is_a_connection_error() {
// Port 1 is reserved and nothing listens on it, so the connect fails fast.
let output = run(&[
"--no-config",
"--username",
"u",
"--password",
"p",
"--server",
"127.0.0.1:1",
"--no-listener",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn without_a_terminal_the_tui_reports_instead_of_panicking() {
// Captured stdout is not a terminal, which is exactly the cron/CI case.
let output = run(&["--no-config"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(
stderr(&output).contains("terminal"),
"should explain why, got: {}",
stderr(&output)
);
}
#[test]
fn portmap_emits_a_record_whose_verdict_matches_the_exit_code() {
let output = run(&["--no-config", "--quiet", "--json", "portmap"]);
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("portmap should emit valid JSON");
assert!(record["port"].is_number());
let ok = record["ok"].as_bool().expect("ok should be a boolean");
// Whether a router answers depends on the network, but the exit code and
// the record must always agree.
assert_eq!(
ok,
code(&output) == EXIT_OK,
"exit code and the ok field must agree"
);
if !ok {
assert_eq!(code(&output), EXIT_NO_RESULTS);
}
}
#[test]
fn quiet_keeps_progress_off_stderr() {
let loud = run(&["--no-config", "--json", "portmap"]);
let quiet = run(&["--no-config", "--quiet", "--json", "portmap"]);
assert!(
stderr(&quiet).lines().count() < stderr(&loud).lines().count(),
"--quiet should suppress the progress narration"
);
assert_eq!(
records(&loud).len(),
records(&quiet).len(),
"--quiet must not change the records"
);
}
// ---------------------------------------------------------------------------
// Server required (soulfind)
// ---------------------------------------------------------------------------
/// Only one test at a time may drive a server.
///
/// Each of these tests runs a soulfind, one or more real clients, and the
/// binary itself. Letting the harness start a dozen of those at once starves
/// the servers, and a login that goes unanswered fails a test for a reason
/// that has nothing to do with the code under test. Serializing here keeps the
/// suite deterministic whatever `--test-threads` the runner picks.
static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A soulfind we spawned for one test, held for that test's lifetime.
struct TestServer {
host: String,
port: u16,
child: Option<Child>,
db: Option<PathBuf>,
_gate: std::sync::MutexGuard<'static, ()>,
}
impl TestServer {
fn resolve() -> Option<Self> {
// A test that panicked while holding the gate poisoned it; the lock
// guards nothing but scheduling, so take it back and carry on.
let gate = SERVER_GATE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") {
let (host, port) = addr.rsplit_once(':')?;
let port = port.parse().ok()?;
wait_until_listening(host, port, Duration::from_secs(2))?;
return Some(Self {
host: host.to_string(),
port,
child: None,
db: None,
_gate: gate,
});
}
let bin = soulfind_binary()?;
let port = free_port()?;
let db = std::env::temp_dir().join(format!("soulfind-cli-{port}.db"));
let _ = std::fs::remove_file(&db);
let mut child = Command::new(&bin)
.arg("-p")
.arg(port.to_string())
.arg("-d")
.arg(&db)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
if wait_until_listening("127.0.0.1", port, Duration::from_secs(5))
.is_none()
{
let _ = child.kill();
let _ = child.wait();
return None;
}
Some(Self {
host: "127.0.0.1".to_string(),
port,
child: Some(child),
db: Some(db),
_gate: gate,
})
}
fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
/// Arguments that point the binary at this server as `user`.
///
/// The listener stays on: a Soulseek transfer is delivered over a
/// connection the *uploader* opens back to us, so a downloader without a
/// listener never receives its file.
fn args(&self, user: &str) -> Vec<String> {
vec![
"--no-config".to_string(),
"--quiet".to_string(),
"--server".to_string(),
self.address(),
"--username".to_string(),
user.to_string(),
"--password".to_string(),
"pw".to_string(),
"--listener-port".to_string(),
free_port().expect("listener port").to_string(),
]
}
/// An in-process client, used to stand up the other end of a test.
fn client(&self, user: &str, shares: Vec<String>) -> Client {
let port = free_port().expect("peer port");
let mut client = Client::with_settings(ClientSettings {
username: user.to_string(),
password: "pw".to_string(),
server_address: PeerAddress::new(self.host.clone(), self.port),
enable_listen: true,
listen_port: port,
shared_directories: shares,
});
client.connect().expect("peer connect");
assert!(client.login().expect("peer login"), "peer should log in");
client
}
}
impl Drop for TestServer {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
if let Some(db) = self.db.as_ref() {
let _ = std::fs::remove_file(db);
}
}
}
fn soulfind_binary() -> Option<PathBuf> {
if let Ok(path) = std::env::var("SOULFIND_BIN") {
let path = PathBuf::from(path);
return path.exists().then_some(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.map(|dir| dir.join("soulfind/bin/soulfind"))
.find(|candidate| candidate.exists())
}
fn free_port() -> Option<u16> {
std::net::TcpListener::bind("127.0.0.1:0")
.ok()?
.local_addr()
.ok()
.map(|addr| addr.port())
}
fn wait_until_listening(
host: &str,
port: u16,
timeout: Duration,
) -> Option<()> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(mut addrs) = format!("{host}:{port}").to_socket_addrs()
&& let Some(addr) = addrs.next()
&& TcpStream::connect_timeout(&addr, Duration::from_millis(200))
.is_ok()
{
return Some(());
}
std::thread::sleep(Duration::from_millis(100));
}
None
}
macro_rules! server_or_skip {
() => {
match TestServer::resolve() {
Some(server) => server,
None => {
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
.is_ok_and(|v| v != "0" && !v.is_empty());
assert!(
!required,
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could \
be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)"
);
println!(
"cli e2e skipped: no soulfind server (set SOULFIND_BIN or \
SOULSEEK_TEST_SERVER to run)"
);
return;
}
}
};
}
/// A scratch directory that cleans itself up.
struct Scratch(PathBuf);
impl Scratch {
fn new(label: &str) -> Self {
static COUNTER: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"soulseek-cli-e2e-{label}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("scratch directory");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn display(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Contents of the file every transfer test shares.
fn probe_bytes() -> Vec<u8> {
(0..4096u32).map(|i| (i % 251) as u8).collect()
}
/// Run the binary against `server` as `user`, appending `args`.
fn cli(server: &TestServer, user: &str, args: &[&str]) -> Output {
let mut all = server.args(user);
all.extend(args.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
run(&refs)
}
/// Wait out the SetWaitPort registrations so peer lookups resolve.
fn settle() {
std::thread::sleep(Duration::from_secs(1));
}
#[test]
fn a_wrong_password_is_a_connection_error() {
let server = server_or_skip!();
// Registers the name with password "pw".
let _owner = server.client("cli_e2e_owner", Vec::new());
let output = run(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_owner",
"--password",
"definitely-wrong",
"--no-listener",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
assert!(stdout(&output).is_empty());
}
#[test]
fn search_prints_a_record_that_download_can_consume() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_qwix.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_a", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_seeker_a",
&["--search-timeout", "5", "search", "qwix"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = records(&output)
.into_iter()
.find(|line| line.contains("cli_probe_qwix"))
.expect("the sharer's file should be listed");
// user TAB size TAB bitrate TAB path — the columns `download --stdin`
// reads back.
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 4, "record was {record:?}");
assert_eq!(fields[0], "cli_e2e_sharer_a");
assert_eq!(fields[1], probe_bytes().len().to_string());
assert!(fields[3].contains("cli_probe_qwix.bin"));
}
#[test]
fn search_json_records_carry_the_fields_a_filter_needs() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_zorb.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_b", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_seeker_b",
&["--search-timeout", "5", "--json", "search", "zorb"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value = records(&output)
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.expect("each line should be valid JSON")
})
.find(|value| {
value["path"].as_str().is_some_and(|p| p.contains("zorb"))
})
.expect("the sharer's file should be listed");
assert_eq!(record["user"], "cli_e2e_sharer_b");
assert_eq!(record["size"], probe_bytes().len() as u64);
assert!(record["free_slot"].is_boolean());
assert!(record.get("speed").is_some());
}
#[test]
fn a_search_with_no_matches_exits_with_the_no_results_code() {
let server = server_or_skip!();
let _peer = server.client("cli_e2e_quiet_peer", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_seeker_c",
&["--search-timeout", "3", "search", "nothingmatchesthisqzx"],
);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn browse_lists_shares_as_paths_download_understands() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("browsable.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_c", vec![share.display()]);
settle();
let output =
cli(&server, "cli_e2e_browser", &["browse", "cli_e2e_sharer_c"]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = records(&output)
.into_iter()
.find(|line| line.contains("browsable.bin"))
.expect("the shared file should be listed");
// user TAB size TAB path
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 3, "record was {record:?}");
assert_eq!(fields[0], "cli_e2e_sharer_c");
assert_eq!(fields[1], probe_bytes().len().to_string());
}
#[test]
fn download_fetches_a_file_and_prints_where_it_landed() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("fetch_me.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_d", vec![share.display()]);
settle();
// Learn the peer-side path the way a script would.
let listing =
cli(&server, "cli_e2e_fetcher", &["browse", "cli_e2e_sharer_d"]);
assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing));
let row = records(&listing)
.into_iter()
.find(|line| line.contains("fetch_me.bin"))
.expect("the shared file should be listed");
let remote_path = row.rsplit('\t').next().expect("path column").to_string();
let output = cli(
&server,
"cli_e2e_fetcher",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_sharer_d",
&remote_path,
"--size",
&probe_bytes().len().to_string(),
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1, "one file, one record");
let written = std::fs::read(reported[0].trim())
.expect("the reported path should be the file that was written");
assert_eq!(written, probe_bytes());
}
#[test]
fn download_without_a_size_looks_it_up_in_the_peers_shares() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("sizeless.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_g", vec![share.display()]);
settle();
let listing =
cli(&server, "cli_e2e_sizeless", &["browse", "cli_e2e_sharer_g"]);
let remote_path = records(&listing)
.into_iter()
.find(|line| line.contains("sizeless.bin"))
.and_then(|row| row.rsplit('\t').next().map(String::from))
.expect("the shared file should be listed");
// No --size: the size has to come from the peer's listing, and the remote
// path there is the full backslash path, not the bare file name.
let output = cli(
&server,
"cli_e2e_sizeless",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_sharer_g",
&remote_path,
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1);
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn download_reads_the_records_search_emits_from_stdin() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("piped.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_e", vec![share.display()]);
settle();
let listing =
cli(&server, "cli_e2e_piper", &["browse", "cli_e2e_sharer_e"]);
let row = records(&listing)
.into_iter()
.find(|line| line.contains("piped.bin"))
.expect("the shared file should be listed");
let mut args = server.args("cli_e2e_piper");
args.extend(
[
"--download-dir",
&target.display(),
"download",
"--stdin",
"--timeout",
"40",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let mut child = command(&refs)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(format!("{row}\n").as_bytes())
.expect("write the record");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1);
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn get_searches_picks_and_downloads_in_one_command() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("cli_probe_vroom.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_f", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_getter",
&[
"--search-timeout",
"5",
"--download-dir",
&target.display(),
"get",
"vroom",
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1, "--pick best downloads one file");
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn get_without_matches_exits_no_results_and_downloads_nothing() {
let server = server_or_skip!();
let target = Scratch::new("downloads");
let _peer = server.client("cli_e2e_silent_peer", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_getter_b",
&[
"--search-timeout",
"3",
"--download-dir",
&target.display(),
"get",
"nothingmatchesthisqzx",
],
);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert_eq!(
std::fs::read_dir(target.path())
.expect("download dir")
.count(),
0,
"nothing should have been written"
);
}
#[test]
fn room_say_reaches_a_listener_and_room_list_shows_the_room() {
let server = server_or_skip!();
let listener = server.client("cli_e2e_room_listener", Vec::new());
listener.join_room("cli_e2e_lobby").expect("join");
settle();
let said = cli(
&server,
"cli_e2e_speaker",
&["room", "say", "cli_e2e_lobby", "hello from a script"],
);
assert_eq!(code(&said), EXIT_OK, "stderr: {}", stderr(&said));
assert!(
stdout(&said).is_empty(),
"an action command emits no records"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut heard = false;
while Instant::now() < deadline && !heard {
heard = listener.take_room_events().iter().any(|event| {
matches!(
event,
soulseek_rs::types::RoomEvent::Message { message, .. }
if message == "hello from a script"
)
});
std::thread::sleep(Duration::from_millis(100));
}
assert!(heard, "the room should have received the message");
let listed = cli(&server, "cli_e2e_lister", &["room", "list"]);
assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed));
let row = records(&listed)
.into_iter()
.find(|line| line.ends_with("cli_e2e_lobby"))
.expect("the joined room should be listed");
let (users, _) = row.split_once('\t').expect("users TAB room");
assert!(
users.parse::<u32>().is_ok(),
"the user count should be a bare number, got {users:?}"
);
}
#[test]
fn room_listen_streams_what_is_said_in_the_room() {
let server = server_or_skip!();
let speaker = server.client("cli_e2e_room_speaker", Vec::new());
speaker.join_room("cli_e2e_lobby_b").expect("join");
settle();
let mut args = server.args("cli_e2e_room_reader");
args.extend(
[
"room",
"listen",
"cli_e2e_lobby_b",
"--duration",
"8",
"--json",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let child = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
// Give the listener time to join before saying anything.
std::thread::sleep(Duration::from_secs(3));
speaker
.say_in_room("cli_e2e_lobby_b", "streamed line")
.expect("say");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let heard = records(&output).iter().any(|line| {
serde_json::from_str::<serde_json::Value>(line)
.is_ok_and(|value| value["message"] == "streamed line")
});
assert!(heard, "stdout was: {:?}", stdout(&output));
}
#[test]
fn a_private_message_reaches_its_recipient() {
let server = server_or_skip!();
let recipient = server.client("cli_e2e_recipient", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_sender",
&["message", "send", "cli_e2e_recipient", "ping from a script"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stdout(&output).is_empty(),
"an action command emits no records"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut received = false;
while Instant::now() < deadline && !received {
received = recipient
.take_private_messages()
.iter()
.any(|message| message.message() == "ping from a script");
std::thread::sleep(Duration::from_millis(100));
}
assert!(received, "the recipient should have received the message");
}
#[test]
fn message_read_streams_incoming_private_messages() {
let server = server_or_skip!();
let sender = server.client("cli_e2e_pm_sender", Vec::new());
settle();
let mut args = server.args("cli_e2e_pm_reader");
args.extend(
["--json", "message", "read", "--duration", "8"]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let child = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
std::thread::sleep(Duration::from_secs(3));
sender
.send_private_message("cli_e2e_pm_reader", "inbox line")
.expect("send");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let read = records(&output).iter().any(|line| {
serde_json::from_str::<serde_json::Value>(line).is_ok_and(|value| {
value["message"] == "inbox line"
&& value["user"] == "cli_e2e_pm_sender"
})
});
assert!(read, "stdout was: {:?}", stdout(&output));
}