2026-09-21 12:58:09 +02:00
2026-09-21 13:48:52 +02:00
2026-10-26 17:16:18 +01:00
2026-09-21 13:48:52 +02:00
2026-09-21 12:58:09 +02:00
2026-09-21 12:58:09 +02:00
2026-04-26 12:00:24 +02:00
2025-07-31 20:06:41 +02:00

soulseek-rs

A Soulseek client and lib built in Rust 🦀 for the terminal: TUI, CLI and daemon. 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. Check the website: re-invention.nl/soulseek-rs for more information/documentation.

Quick install:

For this Gitea version, follow Build and run from Gitea below. It includes the Shift+D shortcut to download a track's containing folder.

Demo

soulseek-rs TUI demo

Features

  • Full protocol coverage: 100% of the modern Soulseek protocol's message set — every code the reference clients do not mark obsolete — implemented in soulseek-rs-lib and driven end to end against a real server, not a mock. See the coverage table
  • Search & download: queue from the TUI or fetch in one command with get; filter by bitrate, size, file type, free slots, or terms to exclude
  • Wishlist: wish add what nobody has today; wish run and serve --wishlist keep looking on the interval the server sets
  • Sharing: shares add a directory and your files show up in searches, with serve online so peers can browse and download them
  • Upload queue: a slot cap, server-listed privileged users first, and a truthful answer when a peer asks their place in line
  • Browse: list any user's shared files and download straight from the tree
  • Resumable downloads: a dead transfer leaves a .part, and re-running it asks the peer for only what is missing
  • Cancel a transfer: x in the TUI or transfer cancel from a script stops a download, queued or in progress, and deletes its .part; uploads too
  • Chat rooms: list, join, and talk in public rooms, several open at once
  • Private messages: send and receive, with an inbox in the TUI
  • Firewalled peers: browsing and downloads fall back to server-brokered connections when a peer is unreachable directly
  • Automatic port mapping: UPnP-IGD and NAT-PMP, with portmap to test your router
  • TUI and CLI: every feature reachable without a terminal, records on stdout, the exit code saying what happened
  • Daemon mode: one login several clients share, locally or over the network, so a download outlives the command that queued it

About

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.

A Cargo workspace with two crates: soulseek-rs-lib, the protocol implementation for anyone building their own client, and soulseek-rs, the client built on it. The library stays lean on dependencies and has none today; the client takes them freely.

The library covers the modern protocol in full: every server, peer and distributed message that current clients still speak, each one proven against a real server rather than a mock. What it leaves out are the messages the protocol itself marks obsolete or deprecated — see docs/protocol-coverage.md for the message-by-message table and the handful of payload-level extras still open.

Projects using soulseek-rs-lib

  • seakarr — automated Soulseek music downloader with library quality upgrading.

Built something on the library? Open a PR to add it here, and reserve a client minor version in #12 so your project is identifiable on the network.

Installation

Build and run from Gitea

You need access to this private Gitea repository and an internet connection to download the build dependencies.

Requirements

  • Linux: Install Git and a C compiler/linker (build-essential on Ubuntu/Debian), then install Rust 1.91 or newer using rustup.
  • Windows: Install Git for Windows and Rust. Accept the prompt to install Visual Studio C++ Build Tools, using the Desktop development with C++ workload. Reopen PowerShell after installation.

Download and build

Run these commands in a Linux terminal or Windows PowerShell. Authenticate with Gitea when prompted. The first build may take several minutes.

git clone --branch develop https://git.bloggin.space/dumbgoy/soulseek-rs-modded.git
cd soulseek-rs-modded
cargo build --release -p soulseek-rs

Run on Linux

./target/release/soulseek-rs

Run on Windows (PowerShell)

.\target\release\soulseek-rs.exe

Log in with your Soulseek account. Press s to search, enter your query, and press Enter. Focus the results pane with 2, highlight a track, then press Shift+D to download all files directly in its remote folder, including files absent from the search results. Space selects individual files and Enter downloads that selection. Press ? for shortcuts or q to quit.

Update later

Close the application, run these commands inside soulseek-rs-modded, then launch it again with the command for your operating system:

git pull --ff-only
cargo build --release -p soulseek-rs

Upstream installation options

The installers and packages below install the original upstream project. Use the Gitea build instructions above for this repository's modifications.

The installer picks the right macOS or Linux binary, verifies its sha256, and installs the latest stable release (through Homebrew when it is available):

curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh

To install the current successful build from develop instead, pass the installer's --nightly option through to sh:

curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly

Nightly is a mutable prerelease rebuilt every day and whenever the Nightly workflow is run by hand. It bypasses Homebrew, and may be less stable than a numbered release. A failed download, checksum, or startup check leaves an existing binary untouched. Windows archives for both channels are on the releases page.

Or install it with a package manager. Stable follows the latest release:

# Debian, Ubuntu, Mint (on ARM: arm64 for amd64)
curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb
sudo apt install ./soulseek-rs-amd64.deb

# Fedora, RHEL (on ARM: aarch64 for x86_64)
sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm

# Nix
nix run github:michel/soulseek-rs/master

# Homebrew, on macOS and Linux
brew install michel/tap/soulseek-rs

# cargo-binstall fetches the release binary and compiles nothing
cargo binstall soulseek-rs

# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one
cargo install --locked soulseek-rs

Nightly is the tip of develop. The .deb and .rpm are downloads; Nix, Homebrew and cargo build it from source:

curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb
sudo apt install ./soulseek-rs-nightly-amd64.deb

sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm

nix run github:michel/soulseek-rs/develop

brew install --HEAD michel/tap/soulseek-rs

cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs

A .deb or .rpm does not update itself. Install the newer file over it. A nightly sorts above the release it follows, so going back to stable is a downgrade. In a script apt refuses one without --allow-downgrades, while dnf goes ahead. Both carry the man page and completions for bash, zsh and fish. A release download prints the plain number, 19.0.0. Any Nix build, and a nightly download, reads 19.0.0+git202609141144.deb846b: the last release, the commit's time in UTC, and the commit. A Homebrew --HEAD build reports HEAD-deb846b. A cargo install from git sets no version, so it prints 19.0.0 too.

From source, cargo build --release leaves the binary at target/release/soulseek-rs. To build your own client on the protocol library, run cargo add soulseek-rs-lib.

To package it for a distribution: a musl build is static, a glibc build links only libc, libm and libgcc_s, soulseek-rs completions print <shell> and soulseek-rs man write the completion scripts and the man page to stdout, and a build with SOULSEEK_RS_VERSION set reports that instead of the crate version. The nfpm recipe behind the .deb and .rpm is in packaging/.

Usage

Run it with no subcommand for the interactive TUI:

soulseek-rs

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

The TUI

Results run across the top, each file name in a column of its own with the folder it came from beside it; Searches, Downloads and Info share the row underneath. Tab/Shift-Tab or 1–3 move the focus between panes, z zooms the focused pane to the whole window, w hides it so the others widen (its number brings it back), and W puts the whole layout back. Every list takes ↑/↓, Home/End, PgUp/PgDn and the vim keys, and h/l or ←/→ scroll a long file name, folder or query sideways, 0 and $ to either end. PgUp/PgDn scroll a chat log back through its history, and ? shows every key for where you are. s starts a search, and S runs the highlighted one again.

o opens settings: who this session is logged in as and how much it shares, then the download and shared folders. Enter on a row acts on it — change the password (typed twice, so a typo cannot leave the account with one nobody knows) or log out, which forgets the stored password and comes back to the login screen. Attached to a daemon the login is the daemon's, so the popup offers no logout and a password change goes to the daemon.

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, so without a daemon two commands at once cut each other off. A daemon is one login for as long as you work, your shares indexed once instead of rescanned every invocation, downloads that outlive the command that queued them, and chat collected while nothing is attached. Driving this from a script or an agent, start one.

Commands find it themselves. It listens on daemon.sock in the state directory (~/.local/share/soulseek-rs/state, or wherever SOULSEEK_STATE_DIR points), openable only by your user, with the token remote clients need beside it in daemon.token. Without one running everything behaves as it did before, and --no-daemon forces that for a single 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. The socket appears before the login finishes, and a command arriving early waits on the handshake for up to 30 seconds. Poll daemon status 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. A second TUI shows the searches and transfers already running, queueing in either shows up in both, and the status bar names the daemon it attached to. Closing a window leaves its downloads running. The settings popup edits the daemon's folders, validated there and created nowhere on this machine, and writes the same paths into this machine's config.toml for the next run without a daemon.

Everything touching the network goes through it. config, skills, completions and portmap stay local. 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 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 borrow the daemon's session, so schedule either one to work the list through. With a remote daemon the list lives in the client's config.toml, so wish run from the laptop drives the daemon against the laptop's wishes.

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

Put soulseek-rs on the machine that should be downloading: a home server, a NAS, a Raspberry Pi. On that machine, let the daemon take connections from your network:

soulseek-rs daemon --bind 0.0.0.0:5030

It prints the token on stderr at startup, with the two config set lines to paste on the other machine. daemon token prints it again, always this machine's, so run it on the box. It needs no terminal and nobody logged in; hand it to systemd or launchd. 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.

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>

Every command after that is the normal command, and the laptop needs no Soulseek account of its own:

soulseek-rs get 'selected ambient works'
soulseek-rs daemon status          # what it's doing right now
soulseek-rs daemon stop            # shuts down the remote daemon

Downloads belong to the daemon, so closing the lid does not stop them. They still honour the command's deadline: a download or get reaching --timeout (300s) tells the daemon to drop the transfer before it exits 5. Raise it for a big file, or queue from the attached TUI and quit.

shares add resolves paths against 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. Pushing a share list or download folder to a remote daemon changes its live session while writing the laptop's config file, so put the change in the daemon's own config.toml as well, or push it again after a restart.

That TCP port has no encryption. Fine on a home network; do not expose it to the internet. Reach it over SSH instead, with a tunnel (ssh -L 5030:localhost:5030 nas) or by running the commands on the box.

Build your own remote

The daemon speaks newline-delimited JSON-RPC 2.0, described by an OpenRPC document, soulseek-rs/docs/openrpc.json, which it also serves over rpc.discover. Point a client generator at it for typed bindings in your language. Searches, transfers, rooms, private messages and shares are all on the interface this CLI uses, with no private back channel, and live updates are pushed rather than polled.

Keep both ends on the same release; the handshake refuses mismatched protocol versions. The destination is session-wide, so download.set_dir moves it for every transfer and download.start ignores any directory the caller names. docs/daemon-protocol.md covers 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 transfer cancel <USER> <PATH> # stop a download or upload
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 account password              # change the account's password
soulseek-rs account logout                # forget the stored password
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
soulseek-rs completions print <SHELL>     # the script on stdout, for packaging
soulseek-rs man                           # the man page on stdout

A script usually starts with whoami, which answers "are these credentials good and what am I offering" in one call.

With a daemon attached every command reports the daemon's world: --username and --password go unread, and whoami names the daemon's account, server and download folder. --no-daemon tests this machine's own login instead. shares list reads the config file either way, while shares status and shares reindex ask the session and report what peers can see.

Twenty-two need no credentials: config path|list|get|set, shares list|add|remove, wish add|remove|list, portmap, skills install|uninstall|list, completions install|uninstall|print, man, account logout and daemon token|status|stop. Most never touch the network; daemon status and daemon stop talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. shares status and shares reindex are not among them, because reporting what the network sees means logging in and scanning the folders.

Seven commands perform an action and print nothing, answering with their exit code alone: room say, message send, shares add, shares remove, wish remove, transfer cancel and daemon stop. room listen, message read and serve stream records until --duration seconds pass or, with --follow, until interrupted. The rest print records and exit.

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
account          action user
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:

Command Extra fields
search duration, slots, speed, free_slot
browse directory, bitrate, duration
download / get user, remote path, size, beside the local file
whoami listening, listen_port, download_dir, privilege_seconds
user privileged, shared_folders
shares status the directories array
serve bytes_sent, size, speed, reason (queue place or failure)
daemon status version, protocol, listening, listen_port, shared_folders, shared_files, download_dir, session_loss
portmap port

session_loss is displaced, disconnected, or null while the session holds. 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

--help lists flags but cannot say which JSON keys a record carries or what exit 4 means. That lives in a skill file shipped inside the binary:

soulseek-rs skills install

It writes SKILL.md into every agent it finds in your home directory, today 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

Rerun it after upgrading the binary; an identical copy reports unchanged. skills list shows what is there, and skills uninstall removes only the file this binary wrote. For any other agent, 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 comes from the same clap definition the binary parses with, so it never drifts from --help:

soulseek-rs completions install

It covers whichever of bash, zsh and fish this machine uses, writing each script where that shell already looks. ~/.config/fish/completions is scanned unprompted; bash and zsh also get one marked source line appended to ~/.bashrc or ~/.zshrc, since 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 share index alive, answers searches and browse requests, 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 appear inside a running serve, since every other command is short-lived with nothing to serve, so there is no uploads list. Pausing, resuming, and a queue that outlives one invocation are daemon mode.

With a daemon running serve starts no second server. It attaches and streams the daemon's uploads, --upload-slots resizes 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 --timeout (300s) and --max-concurrent-downloads at a time, so an unattended run always ends. An interrupted one resumes: bytes stream into <file>.part and it is renamed only once whole, so re-running the same download asks the peer to start at the offset already on disk. Partial files are never offered to peers, and deleting the .part starts 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
— — accept_children off
— — use_username_dirs off
--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 1/0/true/false/yes/no. --config <FILE> (or SOULSEEK_CONFIG) reads a different file, --no-config ignores it entirely, and SOULSEEK_CONFIG_DIR and SOULSEEK_STATE_DIR relocate the config and state directories wholesale. The file lives at ~/.config/soulseek-rs/config.toml on macOS and Linux. Every variable above is also read from a .env in the working directory, usually the tidiest way to hand a container its credentials.

accept_children makes this client carry part of the distributed search network: other peers hang from it and every search it receives is passed down to them. It is off by default — each child costs a socket and a copy of the network's whole search stream — and it needs the listener, since a child has to be able to dial in.

config get and config set cover the thirteen settings the file holds (username, server, listener_port, disable_listener, accept_children, download_dir, use_username_dirs, shared_dirs, max_concurrent_downloads, search_timeout, password_cmd, daemon, daemon_token), and list them back at you when you name something else. An empty string clears a key, and shared_dirs takes a comma-separated list. Waits expressed in seconds (--search-timeout, --timeout, --duration) are bounded to one day, so a mistyped flag is rejected rather than hanging.

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"

Changing the password goes through the server; forgetting one is local:

pass show soulseek-new | soulseek-rs account password --new-password-stdin
soulseek-rs account logout          # drop this machine's stored password

The server sends no verdict on a password change, so exit 0 means the request went out. The new password replaces the stored one — the daemon's, when the run is routed to a daemon, since that is the login that has to work after a restart. account logout leaves the account untouched and only clears what this machine kept, so the next start asks again.

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.

Chat and messages

soulseek-rs message send <username> "hello there"
soulseek-rs message read --duration 60    # or --follow until killed
soulseek-rs room list                     # public rooms with user counts
soulseek-rs room listen <room>            # stream messages, joins, leaves
soulseek-rs room say <room> "hello room"  # post one message and exit

In the TUI, m composes a private message and i opens the inbox with an unread counter. c opens the chat-rooms popup: a /-filterable room list busiest first, Enter to join, several rooms open at once as tabs (Tab/Shift-Tab to switch, x to leave, l back to the list), and ↑/↓ through the member list with b to browse someone or m to message them. / searches the open room's log and u its member list; the inbox takes / too. Unread counts appear on the tabs and on the c chat (n) shortcut.

b browses a user's shared files as a tree: ←/→ close and open a folder, J/K jump between folders, H/L close or open every folder, / filters the whole tree by path, and Enter or d downloads a file or a folder's files (under a filter, only the ones it shows).

Connectivity

Browsing and downloading are peer-to-peer, so one side has to accept an incoming connection. With the listener on (the default) the client opens its listen port via UPnP-IGD and NAT-PMP, renews it while running, and removes it on exit. Best-effort: a router with both disabled makes it a no-op, and a log line says to forward the port yourself.

  • The port is --listener-port (SOULSEEK_LISTENER_PORT, default 2234). Forward that TCP port when auto-mapping cannot get it.
  • --no-listener turns the listener and port mapping off; --listener forces it on when the config file disables it.
  • When another process already holds the port, the usual case with several one-shot commands at once, the client takes one the OS picks, says so on stderr, and advertises that one. whoami reports what it really holds.
soulseek-rs portmap            # exits 0 when mapping works, 4 when it does not
soulseek-rs portmap --json     # {"ok":true,"backend":"upnp","external":"…"}

portmap opens the port, reports whether the router allowed it and your external address, then removes the test mapping. The verdict is the exit code, so soulseek-rs portmap || notify-me works as a health check.

Two peers both behind routers with no forwarded port cannot browse each other. That is a Soulseek 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

Four suites run against soulfind, a local Soulseek server: soulseek-rs-lib/tests/e2e.rs covers the protocol library, soulseek-rs/tests/cli_e2e.rs drives the binary the way a script would, soulseek-rs/tests/daemon_protocol_e2e.rs meets the daemon's control protocol as a third party would, and soulseek-rs/tests/tui_e2e.rs drives the window with key presses and reads the screen back. All are server-optional, running when a server is available and skipping otherwise, so cargo test stays green everywhere.

Every suite spawns soulfind from SOULFIND_BIN=/path/to/soulfind (or a soulfind/bin/soulfind checkout in a parent directory) on an ephemeral port with a throwaway database. The library and CLI suites take SOULSEEK_TEST_SERVER=host:port first, to run against a server already up.

# 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 all four e2e suites with SOULSEEK_E2E_REQUIRED=1, so a missing server fails instead of skipping.

Contributing

Issues and pull requests both welcome. Branch off develop, keep the change small, and see CONTRIBUTING.md for the commit convention and the checks CI runs.

License

MIT. See LICENSE.

S
Description
repo for improving soulseek-rs with qol features.
Readme MIT
5.2 MiB
Languages
Rust 92.5%
TypeScript 6%
Shell 1.1%
CSS 0.2%