# 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](fixtures/demo.gif) ## Features - **Search & download** — search the network and queue downloads in the TUI, or fetch a track in one command with `get` - **Sharing** — `shares add` a directory and your files show up in searches; `serve` stays online so 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 ```bash cargo install soulseek-rs ``` ### For Developers Clone and build from source: ```bash 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`: ```toml [dependencies] soulseek-rs-lib = "6" ``` ## Usage Run it with no subcommand for the interactive TUI: ```bash 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 # print matching files soulseek-rs download # fetch one known file soulseek-rs download --stdin # fetch files listed on stdin soulseek-rs get # search, pick, and download soulseek-rs browse # list a user's shared files soulseek-rs room list # public rooms and user counts soulseek-rs room say # post to a room soulseek-rs room listen # stream room messages and joins soulseek-rs message send # send a private message soulseek-rs message read # stream incoming private messages soulseek-rs room users # who is in a room soulseek-rs serve [--follow] # stay online sharing, stream uploads soulseek-rs whoami # confirm credentials and connection soulseek-rs user # a peer's status and share counts soulseek-rs shares list|add|remove|status|reindex soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping soulseek-rs skills install|uninstall|list # teach a coding agent this CLI ``` `whoami`, `config` and `shares list|add|remove` are the ones a script runs first: the config commands need no account at all, and `whoami` answers "are these credentials good and what am I offering" in one call. Eleven of these need no credentials, because they never touch the network: `config path|list|get|set`, `shares list|add|remove`, `portmap` and `skills install|uninstall|list`. Note that `shares status` and `shares reindex` are not among them — they report what the network will actually see, which means logging in and letting the library scan the folders. Every command emits records except the four that perform an action — `room say`, `message send`, `shares add` and `shares remove` say nothing on stdout and answer with their exit code alone. `room listen`, `message read` and `serve` 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 download / get local-path room list users room room users user room listen type room user message message read timestamp user message serve status user sent/size path user user status average-speed shared-files whoami user server shared-folders shared-files shares list ok|missing directory shares status folders files config get|set|list|path key value portmap ok|failed backend external ``` `room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves the message field empty. `serve`'s `status` is `uploading`, `completed`, `cancelled` or `failed`. `user`'s `status` is `online`, `away` or `offline`, and any field the server did not answer prints as `-`. `--json` carries more than text does, because an object has room for fields a column layout does not: `search` adds `duration`, `slots`, `speed` and `free_slot`; `browse` adds `directory`; `download` adds the `user`, remote `path` and `size` alongside the local `file`; `whoami` adds `listening`, `listen_port` and `download_dir`; `user` adds `privileged` and `shared_folders`; `shares status` adds the `directories` array; `serve` adds `bytes_sent`, `size`, `speed` and a `reason` on failure; `portmap` adds `port`. Fields the server never answered are `null` rather than absent, so a missing reply cannot be misread as a zero. `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 # Be a peer: share a folder and log every upload served soulseek-rs shares add ~/Music soulseek-rs serve --follow --json | tee -a uploads.ndjson # Decide whether a peer is worth queueing to before committing soulseek-rs user someuser --json | jq -e '.status != "offline"' ``` ### Agent skills The scriptable surface above is meant to be driven by coding agents as much as by shell scripts, but `--help` cannot tell an agent which JSON keys a record carries or what exit 4 means. That lives in a skill file shipped inside the binary, so a `cargo install` is all you need to hand it to your agent: ```bash soulseek-rs skills install ``` That writes `SKILL.md` into every agent it finds in your home directory — currently Claude Code (`~/.claude/skills`) and opencode (`~/.config/opencode/skill`) — and prints one record per target: ``` installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md ``` Running it again is how you update after upgrading the binary; an identical copy reports `unchanged` rather than rewriting it. `skills list` shows what is there without touching anything, and `skills uninstall` removes it again — only when the file it finds is the one this binary wrote. For an agent this table does not cover, name the directory yourself, or commit the skill next to a project so everyone working on it gets the same brief: ```bash soulseek-rs skills install --dir ~/.config/some-agent/skills soulseek-rs skills install --dir .claude/skills # commit it with the repo ``` ### Sharing `serve` is the mode that makes this client a source: it stays logged in, keeps the listener and the share index alive, answers searches and browse requests from the network, and prints one record per upload as it changes state (`uploading`, `completed`, `cancelled`, `failed`). It ends after `--duration` seconds (an hour by default), or runs until interrupted with `--follow`. ```bash soulseek-rs shares add ~/Music # remembered in config.toml soulseek-rs shares status # what the network will actually see soulseek-rs shares reindex # after adding files on disk soulseek-rs serve --follow ``` Uploads are only visible inside a running `serve` — every other command is a short-lived process with nothing to serve — so there is no separate `uploads list`. Managing transfers across commands (pause, resume, a queue that outlives one invocation) needs the resident daemon that `serve` would grow into; it is not there yet. 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 | Default | | ---- | -------------------- | ----------------- | ------- | | `--username` | `SOULSEEK_USERNAME` | `username` | — (required) | | `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | — (required) | | `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | — | | `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` | | `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `/Soulseek` | | `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir | | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `5` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | | `--log-file` | `SOULSEEK_LOG_FILE` | — | stderr | Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`. `--config ` (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. Every variable above is also read from a `.env` file in the working directory, which is often the tidiest way to hand a container its credentials. `config get` and `config set` cover the nine settings the file can hold — `username`, `server`, `listener_port`, `disable_listener`, `download_dir`, `shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd` — and name the lot back at you when you ask for something else. Setting a key to an empty string clears it, and `shared_dirs` takes a comma-separated list. Every wait expressed in seconds (`--search-timeout`, each `--timeout`, each `--duration`) is bounded to one day, so a mistyped flag is rejected rather than hanging until the heat death of the universe. #### 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 and read private messages from the command line: ```bash soulseek-rs message send "hello there" soulseek-rs message read --duration 60 # or --follow to run until killed ``` In the interactive TUI: - press `m` to compose — type ` ` 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: ```bash soulseek-rs room list # public rooms with user counts soulseek-rs room listen # stream messages, joins, and leaves soulseek-rs room say "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: ```bash 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: ```bash RUST_LOG=trace cargo run ``` To run the tests: ```bash cargo test ``` To run the linter: ```bash cargo clippy ``` To run the formatter: ```bash cargo fmt ``` ### End-to-end tests 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: 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. ```bash # 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](./LICENSE) file for details.