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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -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] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user