Files
soulseek-rs-modded/README.md
T
Michel de Graaf ab5cef8cfe docs: tell the whole daemon story, and stop denying it exists
An audit of the README and the site against the implementation found 38
places the docs were missing, stale, or wrong about daemon mode. Two were
outright false: the community page still answered "want a queue that
outlives the command?" with "slskd has that", and the home page promised
"no daemon, no API to stand up" fifty lines before the section that
describes both.

The download-box walkthrough now tells the truth about its sharp edges: a
pushed folder or share list is live-only on a remote daemon, whose own
config.toml wins at restart; `shares add` validates the path on the
machine you type it on; and a `get` that reaches its `--timeout` cancels
the daemon-side transfer. Its shell example also runs as written now — the
old block put a second command after a foreground daemon that never
returns.

The reference material catches up with the feature: `daemon` and its
subcommands join the command list, the config table gains the
daemon/daemon_token/--no-daemon rows, nine settings become eleven,
`daemon status` and `daemon token` get documented record shapes, exit 3
covers daemons, `queued` joins the serve statuses, the credential-free
count goes 13 to 19, and the action-command count 4 to 6.

And the surprises are written down rather than discovered: the daemon does
not sweep the wishlist on its own; Windows has no socket and so no
auto-attach; the attached TUI's settings form edits the daemon's folders
and rewrites this machine's config.toml; `serve` monitors a daemon's
uploads instead of starting a second server; `whoami` reports the
daemon's world, with --no-daemon as the way out. The OpenRPC section names
the six event methods and the session-wide download.set_dir rule.

Site typechecks, lints, and builds through prerender; every claim was
verified against the cited code before it was written.
2026-07-29 09:22:02 +02:00

33 KiB

soulseek-rs

A soulseek client for the terminal. Built for agents and people who live there.

Search the network, share your files, browse someone's collection, join a room. It runs over ssh on the machine where your music already lives.

Soulseek is a closed-source P2P network from the 2000s, still used by music enthusiasts to share niche music. This repository is that client plus soulseek-rs-lib, the protocol library under it.

re-invention.nl/soulseek-rs: what it does, how to install it, and every config.toml setting.

Demo

soulseek-rs TUI demo

Features

  • Search & download: search the network and queue downloads in the TUI, or fetch a track in one command with get; narrow results by bitrate, size, file type, free slots, or terms to exclude
  • Wishlist: wish add something the network doesn't have today, and wish run (or serve --wishlist) keeps looking for it on the interval the server sets
  • Sharing: shares add a directory and your files show up in searches; serve stays online so peers can browse and download them
  • Upload queue: a slot cap with users the server lists as privileged served first, and peers get a truthful answer when they ask their place in line
  • Browse: list any user's shared files and download straight from the tree
  • Resumable downloads: a transfer that dies leaves a .part file, and re-running the download asks the peer to send only what is missing
  • 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 one-shot commands for scripts and agents: every feature reachable without a terminal, stdout carrying records, the exit code saying what happened
  • Daemon mode: one login several clients share, on this machine or over the network, so a download outlives the command that queued it

Project Goals

A learning exercise in Rust. I've been on Soulseek since the early 2000s, and its closed-source protocol is a good way to learn asynchronous network programming and reverse engineering.

The library stays lean on dependencies and has none today; the client takes them freely.

Project Structure

A Cargo workspace with two crates:

  • soulseek-rs-lib - the protocol implementation, for anyone building their own client
  • soulseek-rs - the client built on it, installable with Homebrew or cargo install

Installation

For Users

Homebrew, on macOS or Linux. A prebuilt binary, so no Rust toolchain:

brew install michel/tap/soulseek-rs

Or with cargo:

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 lands at target/release/soulseek-rs.

For Library Users

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

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

Usage

Run it with no subcommand for the interactive TUI:

soulseek-rs

Everything else is a one-shot command that runs headless.

Daemon mode

A one-shot command logs in, does its job, and exits. Run a daemon instead and it holds the session while everything else borrows it:

soulseek-rs daemon                 # stays in the foreground; leave it running
soulseek-rs search 'aphex twin'    # no login, no flags; uses the daemon
soulseek-rs                        # the TUI attaches to the same session

Why you want this. The server allows one session per account. Without a daemon, two commands at once either cut each other off, or push you into a throwaway username per run. That second option is account churn on infrastructure other people maintain and share. A daemon is one login for as long as you work, and one share index instead of a rescan every invocation. It also lets a download outlive the command that queued it, and keeps collecting chat while nothing is attached. If you drive this from a script or an agent, start one.

Commands find it by themselves. It listens on a Unix socket only your user can open, so there is nothing to configure and no secret to pass around. Docker and ssh-agent make the same arrangement. The socket is daemon.sock in the state directory (~/.local/share/soulseek-rs/state, or wherever SOULSEEK_STATE_DIR points), with the token remote clients need beside it in daemon.token. With no daemon running, everything behaves exactly as it did before; --no-daemon forces that for one run.

Windows has no Unix socket. A daemon there needs --bind ADDR, and every client needs --daemon ADDR and the token even on the same machine.

From a script, start it and wait for it: it takes a second or two to log in, and the socket is there before the login finishes. A command that arrives early attaches to it and then waits on the handshake, up to 30 seconds. Poll daemon status until it answers first.

soulseek-rs daemon status --json >/dev/null 2>&1 || {
  soulseek-rs daemon >/dev/null 2>&1 &
  for _ in $(seq 30); do
    soulseek-rs daemon status --json >/dev/null 2>&1 && break
    sleep 1
  done
}
soulseek-rs daemon status          # who it is, what it shares, who is attached
soulseek-rs daemon stop

Every window is a view of the same session, not a session of its own. Open a second TUI and it shows the searches and transfers already running, including ones the first started; queue something in either and both see it. The status bar names the daemon it is attached to, so it is obvious which you are looking at. Closing a window leaves its downloads running, because they belong to the daemon. The settings popup edits the daemon's folders while you are attached: the paths are the daemon's, they are validated there, and nothing is created on this machine. Applying them also writes those paths into this machine's config.toml, which becomes your local download folder the next time you run without a daemon.

Everything that touches the network goes through it. config, skills, completions and portmap stay local, because they are about this machine rather than a session. shares add/remove, config set download_dir and config set shared_dirs do both: they write the config file and update a running daemon, so a folder is served — or downloaded into — straight away rather than at the daemon's next start. Files land on the daemon's filesystem, and daemon status reports where.

It does not sweep the wishlist. Standing searches re-run inside serve --wishlist or a one-off wish run, both of which use the daemon's session when one is up, so put either on a schedule if you want the list worked through. With a remote daemon the list itself lives in the client's config.toml, so wish run from the laptop drives the daemon against the laptop's wishes.

soulseek-rs daemon --upload-slots N sizes the upload queue for the session it holds: 1 to 1000, where serve stops at 64.

A download box you drive from your laptop

This is what daemon mode is really for. Put soulseek-rs on the machine that should be doing the downloading: a home server, a NAS, a Raspberry Pi, a seedbox. Then drive it from wherever you happen to be.

On that machine, run the daemon and let it accept connections from your network:

soulseek-rs daemon --bind 0.0.0.0:5030

It stays in the foreground and prints the token on stderr as it starts, with the two config set lines to paste on the other machine. daemon token prints it again, and always this machine's token, so run it on the box rather than on the laptop.

It needs no terminal and no one logged in; hand it to systemd or launchd and forget about it. Run the unit as the user who types the commands, or export the same SOULSEEK_STATE_DIR in both, because a client looks for the socket under its own state directory and finds nothing under someone else's.

On your laptop, say once where it lives:

soulseek-rs config set daemon nas.local:5030
soulseek-rs config set daemon_token <the token you copied>

That is the whole setup. Every command from now on is the normal command. Your laptop needs no Soulseek account of its own, because it uses the server's:

soulseek-rs search 'aphex twin' --json
soulseek-rs get 'selected ambient works'
soulseek-rs daemon status          # what it's doing right now

shares add resolves the folder against the filesystem of the machine you type it on, so shares add /srv/music from the laptop exits 2 unless the laptop has that path too. Add the box's folders on the box, or over ssh.

A share list or download folder pushed to a remote daemon changes its live session, and the config file the command wrote is the laptop's. The daemon reads its own config.toml at the next start, so put the change there as well, or push it again after a restart.

Then close the lid. The downloads keep going, because they belong to the daemon, not to the command that asked for them or to the laptop that ran it. Come back tomorrow, run soulseek-rs daemon status again, and pick up where you left off. The files are on the download box, which is where you wanted them.

A transfer outlives the command being killed and the laptop going away, but not the command's own deadline. A download or get that reaches --timeout (300s by default) tells the daemon to drop the transfer before it exits 5. Raise --timeout for a big file, or queue it from the attached TUI and quit.

When you are finished with it:

soulseek-rs daemon stop            # shuts down the remote daemon

One caution: there is no encryption on that TCP port. On a home network that is usually fine. Over the internet, don't expose it. Reach it through SSH instead, either with a tunnel (ssh -L 5030:localhost:5030 nas) or by running the commands over ssh on the box itself.

Build your own remote

The daemon's interface is open and documented, so nothing about it is specific to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described by an OpenRPC document, docs/openrpc.json. The daemon serves that document itself via rpc.discover, so a client can ask a running daemon what it can do.

OpenRPC is the JSON-RPC counterpart to OpenAPI: point a client generator at that document and you get typed bindings in whatever language you are working in. If you want an Android or iOS app that queues downloads on your home server from the sofa, or a small web page that shows what is transferring, you have everything you need and no permission to ask for. Searches, transfers, rooms, private messages and shares are all on the same interface this CLI uses. There is no private back channel it keeps for itself, and live updates are pushed to every connected client rather than polled for.

The handshake refuses a client and a daemon on different protocol versions, so keep both ends on the same release. A destination is session-wide: download.set_dir moves it for every transfer, and download.start ignores any directory the caller names.

docs/daemon-protocol.md covers the parts a schema cannot state: the framing, the handshake, and how pushed events behave.

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 a daemon, 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
7 the session ended mid-command; nothing it saw is reliable

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 wish add|remove|list <QUERY>  # keep looking for something
soulseek-rs wish run                      # search every stored wish once
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 room users <ROOM>             # who is in a room
soulseek-rs serve [--follow]              # stay online sharing, stream uploads
soulseek-rs daemon [--bind ADDR]          # run as a service others share
soulseek-rs daemon token|status|stop      # control a running one
soulseek-rs whoami                        # confirm credentials and connection
soulseek-rs user <NAME>                   # 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
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish

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.

With a daemon attached, every command reports the daemon's world. The run borrows its session, so --username and --password go unread and whoami names the daemon's account, server and download folder; pass --no-daemon when you want to test this machine's own login. shares list stays local either way, because it reads the config file, while shares status and shares reindex ask the session and so report what peers can see.

Nineteen of these need no credentials: config path|list|get|set, shares list|add|remove, wish add|remove|list, portmap, skills install|uninstall|list, completions install|uninstall and daemon token|status|stop. Most never touch the network at all; daemon status and daemon stop talk to a daemon that is already logged in, so a machine with no Soulseek account of its own can still ask whether one is running. shares status and shares reindex are not among them: they report what the network will see, which means logging in and scanning the folders.

Every command emits records except the six that perform an action. room say, message send, shares add, shares remove, wish remove and daemon stop 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
daemon status    user   server  clients         uptime-secs
daemon token     token
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 queued, 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: 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, download_dir and privilege_seconds; user adds privileged and shared_folders; shares status adds the directories array; serve adds bytes_sent, size, speed and a reason carrying the queue place or the failure; daemon status adds version, protocol, listening, listen_port, shared_folders, shared_files, download_dir and session_loss (displaced, disconnected, or null while the session holds); 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. That is why 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

# Nobody has it today: keep looking, and fetch it the day someone does
soulseek-rs wish add "some rare 12 inch"
soulseek-rs wish run --json | soulseek-rs download --stdin

# Only lossless, nothing from a bootleg folder, album-sized files only
soulseek-rs search "boards of canada" \
  --extension flac --exclude bootleg --min-size 20000000

# 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

an agent loading the soulseek-rs skill and downloading tracks

Coding agents drive the surface above as readily as shell scripts do, 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 cargo install is all it takes to hand it over:

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, but 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:

soulseek-rs skills install --dir ~/.config/some-agent/skills
soulseek-rs skills install --dir .claude/skills   # commit it with the repo

Shell completions

Tab completion is generated from the same clap definition the binary parses with, so it never drifts from --help:

soulseek-rs completions install

That covers whichever of bash, zsh and fish this machine uses, writing each script where that shell already looks — ~/.config/fish/completions is scanned unprompted, while bash and zsh also get one marked source line appended to ~/.bashrc or ~/.zshrc, because nothing else puts a per-user directory on their search path. Open a new shell and soulseek-rs sh<TAB> completes.

installed   zsh    /home/you/.local/share/zsh/site-functions/_soulseek-rs
installed   fish   /home/you/.config/fish/completions/soulseek-rs.fish

completions uninstall removes the scripts and takes back exactly the lines it added, leaving the rest of your rc file alone. Pass --shell bash (repeatable) to act on one shell rather than the ones detected.

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 (queued, uploading, completed, cancelled, failed). It ends after --duration seconds (an hour by default), or runs until interrupted with --follow.

soulseek-rs shares add ~/Music     # remembered in config.toml
soulseek-rs shares status          # what the network will see
soulseek-rs shares reindex         # after adding files on disk
soulseek-rs serve --follow         # add --upload-slots N to widen the queue

Uploads only show up inside a running serve; every other command is short-lived with nothing to serve, so there is no uploads list. Managing transfers across commands (pause, resume, a queue that outlives one invocation) is what daemon mode is for.

With a daemon running, serve starts no second server. It attaches and streams the daemon's uploads, --upload-slots re-sizes the daemon's queue, and the local "nothing to share" and "needs the listener" checks are skipped, because the daemon's shares and listener are the ones peers reach.

Downloads run under a deadline (--timeout, 300s by default) and --max-concurrent-downloads at a time, so an unattended run always ends.

An interrupted download is picked up where it stopped. Bytes stream into <file>.part as they arrive and it is renamed only once the file is whole, so re-running the same download asks the peer to start at the offset already on disk. Partial files are never offered to other peers, and deleting the .part starts the transfer over.

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 unset
--server SOULSEEK_SERVER server server.slsknet.org:2416
--download-dir SOULSEEK_DOWNLOAD_DIR download_dir <Downloads>/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 20
--search-timeout SOULSEEK_SEARCH_TIMEOUT search_timeout 10
--daemon ADDR SOULSEEK_DAEMON daemon local socket if one is up
--daemon-token SOULSEEK_DAEMON_TOKEN daemon_token unset (TCP only)
--no-daemon — — attach if one is running
--log-file SOULSEEK_LOG_FILE not a config key stderr

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. 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 eleven settings the file can hold (username, server, listener_port, disable_listener, download_dir, shared_dirs, max_concurrent_downloads, search_timeout, password_cmd, daemon, daemon_token), 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.
# 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, then type <recipient> <message> and Enter to send;
  • press i to open the inbox popup listing sent and received messages (the TUI receives incoming messages while it 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 tries to open its listen port on your router via UPnP-IGD and NAT-PMP, so firewalled peers and the server can connect back. This is best-effort: if your router has UPnP/NAT-PMP disabled it's a no-op, and a log line tells you to forward the port yourself.

  • The mapped/forwarded port is your --listener-port (env SOULSEEK_LISTENER_PORT, default 2234); the client renews it while it runs and removes it on exit.
  • If auto-mapping can't get that 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.
  • If another process on this machine already holds that port — the usual case when several one-shot commands run at once — the client takes one the OS picks instead, says so on stderr, and advertises and maps that one. whoami reports the port it really holds.

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

RUST_LOG=trace cargo run   # run with debug and trace output
cargo test
cargo clippy
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), which spawns 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. CI sets it, so the e2e suite runs against a freshly built soulfind instead of 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

MIT. See LICENSE.