Files
soulseek-rs-modded/README.md
T
Michel de Graaf be6581ee6c 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.
2026-07-25 01:30:48 +02:00

14 KiB

Soulseek-rs 🦀

Soulseek-rs is a Soulseek library and client built in Rust. Soulseek is a closed-source P2P file-sharing network from the 2000s. It's still used by music enthusiasts around the world to share niche music.

🎥 Demo

CLI Demo

Features

  • Search & download — search the network, pick results in the TUI, and queue downloads with pause, resume, and retry
  • Sharing — point --shared-dir at a directory and your files show up in searches; peers can browse and download them
  • Browse — list any user's shared files and download straight from the tree
  • Chat rooms — list, join, and talk in public rooms, several open at once
  • Private messages — send and receive messages, with an inbox in the TUI
  • Firewalled peers — downloads and browsing fall back to server-brokered 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 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

This project is a learning exercise to explore Rust. I've been using Soulseek since the early 2000s, so it's close to my heart, and the Soulseek protocol is a closed-source network protocol that provides a great opportunity to learn about asynchronous and concurrent network programming and reverse engineering

Since it's a learning project, I have a self-imposed restriction not to use external dependencies in the library. This means I can't use any external crates that are not part of the Rust standard library. This is a good challenge to learn how to build complex systems with only the standard library.

In the client crate, external dependencies are allowed for building a rich experience. For me, this is a good balance between learning and practicality.

Planned Features

  • Headless mode daemon mode with remote control

Project Structure

This project is organized as a Cargo workspace with two crates:

  • soulseek-rs-lib - The core library implementing the Soulseek protocol
  • soulseek-rs - A CLI client built on top of the library

This structure allows:

  • Other developers to build custom Soulseek clients using soulseek-rs-lib
  • Users to install the ready-made client via cargo install soulseek-rs
  • Clean separation of concerns between protocol implementation and user interface

Installation

For Users

cargo install soulseek-rs

For Developers

Clone and build from source:

git clone git@github.com:michel/soulseek-rs.git
cd soulseek-rs
cargo build --release

The binary will be available at target/release/soulseek-rs.

For Library Users

To build your own Soulseek client, add to your Cargo.toml:

[dependencies]
soulseek-rs-lib = "6"

Usage

Run it with no subcommand for the interactive TUI:

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

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

# 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.
# 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 and read private messages from the command line:

soulseek-rs message send <username> "hello there"
soulseek-rs message read --duration 60      # or --follow to run until killed

In the interactive TUI:

  • press m to compose — type <recipient> <message> and Enter to send;
  • press i to open the inbox popup listing sent and received messages (incoming messages arrive automatically while the TUI is open). The i shortcut shows an unread counter, e.g. i inbox (3).

Chat rooms

From the command line:

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:

  • the room list is browsable and /-filterable and shows each room's user count (busiest first); press Enter to join the highlighted room;
  • several rooms can be open at once as tabs — Tab/Shift-Tab switch between them, x leaves the active room, l returns to the room list;
  • in a room, press Enter to type a message and Enter again to send;
  • the room's member list is selectable with ↑/↓; press b to browse the highlighted user's shared files or m to send them a private message;
  • unread messages bold a room's tab and add a room (n) badge, and the c chat (n) shortcut counts unread across all open rooms.

Connectivity (being reachable)

Browsing and downloading are peer-to-peer, so at least one side must accept an incoming connection. When the listener is enabled (the default), the client automatically tries to open its listen port on your router via UPnP-IGD 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 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 --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:

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, 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 bug.

Development

To run the project in development mode with debug output and trace output:

RUST_LOG=trace cargo run

To run the tests:

cargo test

To run the linter:

cargo clippy

To run the formatter:

cargo fmt

End-to-end tests

Two suites exercise the client against a real Soulseek server using 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:

  1. SOULSEEK_TEST_SERVER=host:port — connect to an already-running server, or
  2. SOULFIND_BIN=/path/to/soulfind (or a soulfind/bin/soulfind checkout in a parent directory) — spawn soulfind on an ephemeral port with a throwaway database.
# Build soulfind once (see its BUILDING.md), then:
SOULFIND_BIN=/path/to/soulfind/bin/soulfind \
  cargo test --workspace -- --nocapture

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 runs against a freshly built soulfind rather than silently skipping.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request:

  • Format & Clippy — cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings.
  • 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

This project is licensed under the MIT License — see the LICENSE file for details.