Closes the last two gaps in Soulseek's list of features an alternative client must implement to be tolerated, and adds the result filters Nicotine+ has had all along. Wishlist: server code 103 is sent and code 104's announced interval is kept rather than logged and discarded, so the re-search timer runs off the schedule the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes persist in config.toml as a TOML array so a comma inside a query survives. `wish run` prints search-shaped records, so it pipes into `download --stdin`. Privilege recognition: the code-69 user list is parsed instead of counted and dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go through a queue with a slot cap (`--upload-slots`, default 2) that serves privileged users first — the cap is what makes the ordering mean anything, so they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it previously had no handler at all, and a departed peer's slot is released so a stalled peer cannot wedge uploads shut. Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside the existing `--min-bitrate` and `--free-slots`, in one flattened arg group shared by `search`, `get` and `wish run`. Also serialises the library e2e suite behind the same gate `cli_e2e` already uses: the new queue tests hold peer sockets open on purpose, which starved concurrently-spawned soulfinds and failed unrelated download tests with `login: Timeout`. BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive match on it no longer compiles, and `cli::Commands` gains `Wish`.
540 lines
23 KiB
Markdown
540 lines
23 KiB
Markdown
<div align="center">
|
|
<picture>
|
|
<source media="(prefers-color-scheme: dark)" srcset="website/public/wordmark-on-dark.svg">
|
|
<img src="website/public/wordmark-on-light.svg" alt="soulseek-rs" width="340">
|
|
</picture>
|
|
</div>
|
|
|
|
**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`](./soulseek-rs-lib), the protocol library under it.
|
|
|
|
**[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)**: what it
|
|
does, how to install it, and every `config.toml` setting.
|
|
|
|
## Demo
|
|
|
|
[](https://re-invention.nl/soulseek-rs/)
|
|
|
|
## 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
|
|
|
|
## 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.
|
|
|
|
## Planned Features
|
|
|
|
- [ ] Headless daemon mode with remote control
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
brew install michel/tap/soulseek-rs
|
|
```
|
|
|
|
Or with cargo:
|
|
|
|
```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 lands at `target/release/soulseek-rs`.
|
|
|
|
### For Library Users
|
|
|
|
To build your own Soulseek client, add to your `Cargo.toml`:
|
|
|
|
```toml
|
|
[dependencies]
|
|
soulseek-rs-lib = "8"
|
|
```
|
|
|
|
## 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 <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 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.
|
|
|
|
Thirteen of these need no credentials, because they never touch the network:
|
|
`config path|list|get|set`, `shares list|add|remove`, `portmap`,
|
|
`skills install|uninstall|list` and `completions install|uninstall`.
|
|
`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 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: `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. That is why `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
|
|
|
|
# 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
|
|
|
|
[](https://re-invention.nl/soulseek-rs/#agents)
|
|
|
|
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:
|
|
|
|
```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, 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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```bash
|
|
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
|
|
(`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 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) needs the resident daemon `serve` would grow into, which 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.
|
|
|
|
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` | `5` |
|
|
| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` |
|
|
| `--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 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 <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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
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
|
|
|
|
```bash
|
|
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](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), which spawns 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. 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](./LICENSE).
|