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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user