docs: cut the README's padding, and tell the skill whose account it is
The feature list said the same things at twice the length, and Project Goals / Project Structure were two headings for one paragraph. SKILL.md argued against throwaway accounts on cost grounds. The reason that holds is standing: the agent arrives under the user's name, peers judge that name, and inventing a second one throws it away. Exit 7 now points at starting a daemon rather than at --username.
This commit is contained in:
@@ -24,85 +24,51 @@ does, how to install it, and every `config.toml` setting.
|
||||
|
||||
## 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
|
||||
- **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 transfer that dies leaves a `.part` file, and
|
||||
re-running the download asks the peer to send only what is missing
|
||||
- **Resumable downloads**: a dead transfer leaves a `.part`, and re-running it
|
||||
asks the peer for 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
|
||||
- **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
|
||||
|
||||
## Project Goals
|
||||
## 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.
|
||||
|
||||
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`
|
||||
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.
|
||||
|
||||
## Installation
|
||||
|
||||
### For Users
|
||||
|
||||
Homebrew, on macOS or Linux. A prebuilt binary, so no Rust toolchain:
|
||||
Homebrew ships a prebuilt binary, so no Rust toolchain:
|
||||
|
||||
```bash
|
||||
brew install michel/tap/soulseek-rs
|
||||
brew install michel/tap/soulseek-rs # macOS and Linux
|
||||
cargo install soulseek-rs # or from crates.io
|
||||
```
|
||||
|
||||
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"
|
||||
```
|
||||
From source, `cargo build --release` leaves the binary at
|
||||
`target/release/soulseek-rs`. To build your own client on the protocol library,
|
||||
add `soulseek-rs-lib = "8"` to your `Cargo.toml`.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -125,29 +91,25 @@ 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.
|
||||
**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 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.
|
||||
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: 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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
soulseek-rs daemon status --json >/dev/null 2>&1 || {
|
||||
@@ -164,57 +126,45 @@ 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.
|
||||
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 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*
|
||||
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 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.
|
||||
`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.
|
||||
|
||||
`soulseek-rs daemon --upload-slots N` sizes the upload queue for the session it
|
||||
holds: 1 to 1000, where `serve` stops at 64.
|
||||
`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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -223,71 +173,46 @@ 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:
|
||||
Every command after that is the normal command, and the laptop needs no
|
||||
Soulseek account of its own:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
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'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](https://open-rpc.org) document,
|
||||
[`docs/openrpc.json`](docs/openrpc.json). The daemon serves that document
|
||||
itself via `rpc.discover`, so a client can ask a running daemon what it can
|
||||
do.
|
||||
The daemon speaks newline-delimited JSON-RPC 2.0, described by an
|
||||
[OpenRPC](https://open-rpc.org) document,
|
||||
[`docs/openrpc.json`](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.
|
||||
|
||||
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`](docs/daemon-protocol.md) covers the parts a schema
|
||||
cannot state: the framing, the handshake, and how pushed events behave.
|
||||
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`](docs/daemon-protocol.md) covers the framing, the
|
||||
handshake, and how pushed events behave.
|
||||
|
||||
### Scripting
|
||||
|
||||
@@ -338,31 +263,29 @@ 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.
|
||||
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. 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.
|
||||
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.
|
||||
|
||||
Nineteen of these need no credentials: `config path|list|get|set`,
|
||||
Nineteen 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
Six commands perform an action and print nothing, answering with their exit
|
||||
code alone: `room say`, `message send`, `shares add`, `shares remove`,
|
||||
`wish remove` 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
|
||||
|
||||
@@ -394,17 +317,23 @@ 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.
|
||||
`--json` carries more than text does:
|
||||
|
||||
| Command | Extra fields |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `search` | `duration`, `slots`, `speed`, `free_slot` |
|
||||
| `browse` | `directory` |
|
||||
| `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
|
||||
@@ -457,30 +386,25 @@ soulseek-rs user someuser --json | jq -e '.status != "offline"'
|
||||
|
||||
[](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:
|
||||
`--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:
|
||||
|
||||
```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:
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -490,18 +414,18 @@ 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`:
|
||||
Tab completion comes 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.
|
||||
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
|
||||
@@ -515,11 +439,10 @@ 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`.
|
||||
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`.
|
||||
|
||||
```bash
|
||||
soulseek-rs shares add ~/Music # remembered in config.toml
|
||||
@@ -528,24 +451,21 @@ 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](#daemon-mode) is for.
|
||||
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](#daemon-mode).
|
||||
|
||||
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.
|
||||
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 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.
|
||||
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
|
||||
|
||||
@@ -569,24 +489,22 @@ file, in that order of precedence. Flags work before or after the subcommand.
|
||||
| `--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.
|
||||
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.
|
||||
|
||||
`config get` and `config set` cover the eleven settings the file can hold
|
||||
`config get` and `config set` cover the eleven settings the file holds
|
||||
(`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.
|
||||
`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
|
||||
|
||||
@@ -610,80 +528,50 @@ 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:
|
||||
### Chat and messages
|
||||
|
||||
```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 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, and leaves
|
||||
soulseek-rs room listen <room> # stream messages, joins, 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:
|
||||
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.
|
||||
Unread counts appear on the tabs and on the `c chat (n)` shortcut.
|
||||
|
||||
- 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
|
||||
|
||||
### Connectivity (being reachable)
|
||||
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.
|
||||
|
||||
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:
|
||||
- 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.
|
||||
|
||||
```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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
Two peers both behind routers with no forwarded port cannot browse each other.
|
||||
That is a Soulseek limitation, not a bug.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -696,13 +584,11 @@ 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).
|
||||
Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a
|
||||
local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
|
||||
library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
|
||||
would. Both are **server-optional**, running when a server is available and
|
||||
skipping otherwise, so `cargo test` stays green everywhere.
|
||||
|
||||
They locate a server in this order:
|
||||
|
||||
|
||||
+15
-14
@@ -41,18 +41,19 @@ by themselves. It listens on a Unix socket only your user can open, so there is
|
||||
nothing to configure, no address to pass, and no token to handle.
|
||||
|
||||
**Why this matters, and why it is not optional for you.** Soulseek is a real
|
||||
network of other people's computers, and the server allows **one session per
|
||||
account**. Without a daemon, every invocation is its own login, and running two
|
||||
at once leaves you two bad choices: share a username and they silently cut each
|
||||
other off (exit 7), or give each run a throwaway name and *register a new
|
||||
account on a public server for every search you make*. The second is what a
|
||||
naive script does, and it is the antisocial one: account churn on
|
||||
infrastructure other people maintain and share.
|
||||
network of other people's computers, and you arrive on it under the user's one
|
||||
account. That name is their standing: peers see what it shares, add it to their
|
||||
lists, and queue it again later. The server allows **one session per account**,
|
||||
so without a daemon every invocation is its own login and two at once silently
|
||||
cut each other off (exit 7). Never work around that by inventing usernames — an
|
||||
account per command is churn on infrastructure other people maintain, and it
|
||||
throws away the standing the user built.
|
||||
|
||||
A daemon is one login for as long as you work, however many commands you run
|
||||
against it. It also indexes your shared folders once instead of re-scanning
|
||||
them on every invocation, and downloads it starts outlive the command that
|
||||
asked for them, so a transfer survives the process that queued it.
|
||||
against it. It also keeps the user's shared folders indexed and reachable
|
||||
instead of re-scanning them on every invocation, and downloads it starts
|
||||
outlive the command that asked for them, so a transfer survives the process
|
||||
that queued it.
|
||||
|
||||
So: **one daemon, then as many commands as you like.** Reach for `--no-daemon`
|
||||
only when you deliberately want an isolated session, and expect to justify it.
|
||||
@@ -86,7 +87,7 @@ that form; the columns are a subset of the JSON keys.
|
||||
| 4 | It worked and found nothing | Widen or correct the query — retrying it verbatim will not help |
|
||||
| 5 | Gave up waiting | Raise `--timeout`, or `--search-timeout` for searches |
|
||||
| 6 | A transfer started and did not finish | The peer stalled; try another result |
|
||||
| 7 | The session ended mid-command (usually another login took the username) | Nothing this run saw is reliable; retry, and give each concurrent run its own `--username` |
|
||||
| 7 | The session ended mid-command (usually another login took the username) | Nothing this run saw is reliable; start a daemon so concurrent runs share the one login, then retry — never invent a second username |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -481,9 +482,9 @@ transfers on one person.
|
||||
or any fetch whose size you do not know.
|
||||
|
||||
Twenty at once works only because the daemon holds the session: twenty commands
|
||||
sharing one login. Without a daemon they would displace each other (exit 7),
|
||||
and giving each its own `--username` would register a throwaway account per
|
||||
track, which is worse. Start the daemon first.
|
||||
sharing one login. Without a daemon they displace each other (exit 7). Do not
|
||||
reach for `--username` to get around it: that registers an account per track on
|
||||
someone else's server. Start the daemon first.
|
||||
|
||||
Keep the listener on: a peer that cannot connect back to you cannot send you
|
||||
the file.
|
||||
|
||||
Reference in New Issue
Block a user