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
|
## Features
|
||||||
|
|
||||||
- **Search & download**: search the network and queue downloads in the TUI,
|
- **Search & download**: queue from the TUI or fetch in one command with `get`;
|
||||||
or fetch a track in one command with `get`; narrow results by bitrate, size,
|
filter by bitrate, size, file type, free slots, or terms to exclude
|
||||||
file type, free slots, or terms to exclude
|
- **Wishlist**: `wish add` what nobody has today; `wish run` and
|
||||||
- **Wishlist**: `wish add` something the network doesn't have today, and
|
`serve --wishlist` keep looking on the interval the server sets
|
||||||
`wish run` (or `serve --wishlist`) keeps looking for it on the interval the
|
- **Sharing**: `shares add` a directory and your files show up in searches,
|
||||||
server sets
|
with `serve` online so peers can browse and download them
|
||||||
- **Sharing**: `shares add` a directory and your files show up in searches;
|
- **Upload queue**: a slot cap, server-listed privileged users first, and a
|
||||||
`serve` stays online so peers can browse and download them
|
truthful answer when a peer asks their place in line
|
||||||
- **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
|
- **Browse**: list any user's shared files and download straight from the tree
|
||||||
- **Resumable downloads**: a transfer that dies leaves a `.part` file, and
|
- **Resumable downloads**: a dead transfer leaves a `.part`, and re-running it
|
||||||
re-running the download asks the peer to send only what is missing
|
asks the peer for only what is missing
|
||||||
- **Chat rooms**: list, join, and talk in public rooms, several open at once
|
- **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
|
- **Private messages**: send and receive, with an inbox in the TUI
|
||||||
- **Firewalled peers**: downloads and browsing fall back to server-brokered
|
- **Firewalled peers**: browsing and downloads fall back to server-brokered
|
||||||
connections when a peer can't be reached directly
|
connections when a peer is unreachable directly
|
||||||
- **Automatic port mapping**: opens your listen port via UPnP-IGD and
|
- **Automatic port mapping**: UPnP-IGD and NAT-PMP, with `portmap` to test your
|
||||||
NAT-PMP, with a `portmap` subcommand to test your router
|
router
|
||||||
- **TUI and CLI**: a full terminal interface, plus one-shot commands for
|
- **TUI and CLI**: every feature reachable without a terminal, records on
|
||||||
scripts and agents: every feature reachable without a terminal, stdout
|
stdout, the exit code saying what happened
|
||||||
carrying records, the exit code saying what happened
|
- **Daemon mode**: one login several clients share, locally or over the network,
|
||||||
- **Daemon mode**: one login several clients share, on this machine or over
|
so a download outlives the command that queued it
|
||||||
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
|
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
|
its closed-source protocol is a good way to learn asynchronous network
|
||||||
programming and reverse engineering.
|
programming and reverse engineering.
|
||||||
|
|
||||||
The library stays lean on dependencies and has none today; the client takes
|
A Cargo workspace with two crates: **soulseek-rs-lib**, the protocol
|
||||||
them freely.
|
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;
|
||||||
## Project Structure
|
the client takes them freely.
|
||||||
|
|
||||||
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
|
## Installation
|
||||||
|
|
||||||
### For Users
|
Homebrew ships a prebuilt binary, so no Rust toolchain:
|
||||||
|
|
||||||
Homebrew, on macOS or Linux. A prebuilt binary, so no Rust toolchain:
|
|
||||||
|
|
||||||
```bash
|
```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:
|
From source, `cargo build --release` leaves the binary at
|
||||||
|
`target/release/soulseek-rs`. To build your own client on the protocol library,
|
||||||
```bash
|
add `soulseek-rs-lib = "8"` to your `Cargo.toml`.
|
||||||
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
|
## 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
|
soulseek-rs # the TUI attaches to the same session
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why you want this.** The server allows one session per account. Without a
|
**Why you want this.** The server allows one session per account, so without a
|
||||||
daemon, two commands at once either cut each other off, or push you into a
|
daemon two commands at once cut each other off. A daemon is one login for as
|
||||||
throwaway username per run. That second option is account churn on
|
long as you work, your shares indexed once instead of rescanned every
|
||||||
infrastructure other people maintain and share. A daemon is one login for as
|
invocation, downloads that outlive the command that queued them, and chat
|
||||||
long as you work, and one share index instead of a rescan every invocation. It also lets a download
|
collected while nothing is attached. Driving this from a script or an agent,
|
||||||
outlive the command that queued it, and keeps collecting chat while nothing is
|
start one.
|
||||||
attached. If you drive this from a script or an agent, start one.
|
|
||||||
|
|
||||||
Commands find it by themselves. It listens on a Unix socket only your user can
|
Commands find it themselves. It listens on `daemon.sock` in the state directory
|
||||||
open, so there is nothing to configure and no secret to pass around. Docker and
|
(`~/.local/share/soulseek-rs/state`, or wherever `SOULSEEK_STATE_DIR` points),
|
||||||
ssh-agent make the same arrangement. The socket is `daemon.sock` in the state
|
openable only by your user, with the token remote clients need beside it in
|
||||||
directory (`~/.local/share/soulseek-rs/state`, or wherever `SOULSEEK_STATE_DIR`
|
`daemon.token`. Without one running everything behaves as it did before, and
|
||||||
points), with the token remote clients need beside it in `daemon.token`. With
|
`--no-daemon` forces that for a single run.
|
||||||
no daemon running, everything behaves exactly as it did before; `--no-daemon`
|
|
||||||
forces that for one run.
|
|
||||||
|
|
||||||
Windows has no Unix socket. A daemon there needs `--bind ADDR`, and every
|
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.
|
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,
|
From a script, start it and *wait* for it. The socket appears before the login
|
||||||
and the socket is there before the login finishes. A command that arrives early
|
finishes, and a command arriving early waits on the handshake for up to 30
|
||||||
attaches to it and then waits on the handshake, up to 30 seconds. Poll
|
seconds. Poll `daemon status` first.
|
||||||
`daemon status` until it answers first.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs daemon status --json >/dev/null 2>&1 || {
|
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
|
soulseek-rs daemon stop
|
||||||
```
|
```
|
||||||
|
|
||||||
Every window is a view of the same session, not a session of its own. Open a
|
Every window is a view of the same session. A second TUI shows the searches and
|
||||||
second TUI and it shows the searches and transfers already running, including
|
transfers already running, queueing in either shows up in both, and the status
|
||||||
ones the first started; queue something in either and both see it. The status
|
bar names the daemon it attached to. Closing a window leaves its downloads
|
||||||
bar names the daemon it is attached to, so it is obvious which you are looking
|
running. The settings popup edits the daemon's folders, validated there and
|
||||||
at. Closing a window leaves its downloads running, because they belong to the
|
created nowhere on this machine, and writes the same paths into this machine's
|
||||||
daemon. The settings popup edits the daemon's folders while you are attached:
|
`config.toml` for the next run without a daemon.
|
||||||
the paths are the daemon's, they are validated there, and nothing is created on
|
|
||||||
this machine. Applying them also writes those paths into this machine's
|
|
||||||
`config.toml`, which becomes your local download folder the next time you run
|
|
||||||
without a daemon.
|
|
||||||
|
|
||||||
Everything that touches the network goes through it. `config`, `skills`,
|
Everything touching the network goes through it. `config`, `skills`,
|
||||||
`completions` and `portmap` stay local, because they are about this machine
|
`completions` and `portmap` stay local. `shares add`/`remove`,
|
||||||
rather than a session. `shares add`/`remove`, `config set download_dir` and
|
`config set download_dir` and `config set shared_dirs` do both: they write the
|
||||||
`config set shared_dirs` do both: they write the config file *and* update a
|
config file *and* update a running daemon, so a folder is served or downloaded
|
||||||
running daemon, so a folder is served — or downloaded into — straight away
|
into straight away rather than at the next start. Files land on the *daemon's*
|
||||||
rather than at the daemon's next start. Files land on the *daemon's*
|
|
||||||
filesystem, and `daemon status` reports where.
|
filesystem, and `daemon status` reports where.
|
||||||
|
|
||||||
It does not sweep the wishlist. Standing searches re-run inside
|
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
|
`serve --wishlist` or a one-off `wish run`, both of which borrow the daemon's
|
||||||
session when one is up, so put either on a schedule if you want the list worked
|
session, so schedule either one to work the list through. With a remote daemon
|
||||||
through. With a remote daemon the list itself lives in the client's
|
the list lives in the client's `config.toml`, so `wish run` from the laptop
|
||||||
`config.toml`, so `wish run` from the laptop drives the daemon against the
|
drives the daemon against the laptop's wishes.
|
||||||
laptop's wishes.
|
|
||||||
|
|
||||||
`soulseek-rs daemon --upload-slots N` sizes the upload queue for the session it
|
`daemon --upload-slots N` sizes the upload queue for the session it holds, 1 to
|
||||||
holds: 1 to 1000, where `serve` stops at 64.
|
1000, where `serve` stops at 64.
|
||||||
|
|
||||||
#### A download box you drive from your laptop
|
#### A download box you drive from your laptop
|
||||||
|
|
||||||
This is what daemon mode is really for. Put soulseek-rs on the machine that
|
Put soulseek-rs on the machine that should be downloading: a home server, a
|
||||||
should be doing the downloading: a home server, a NAS, a Raspberry Pi, a
|
NAS, a Raspberry Pi. **On that machine**, let the daemon take connections from
|
||||||
seedbox. Then drive it from wherever you happen to be.
|
your network:
|
||||||
|
|
||||||
**On that machine**, run the daemon and let it accept connections from your
|
|
||||||
network:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs daemon --bind 0.0.0.0:5030
|
soulseek-rs daemon --bind 0.0.0.0:5030
|
||||||
```
|
```
|
||||||
|
|
||||||
It stays in the foreground and prints the token on stderr as it starts, with
|
It prints the token on stderr at startup, with the two `config set` lines to
|
||||||
the two `config set` lines to paste on the other machine. `daemon token` prints
|
paste on the other machine. `daemon token` prints it again, always *this*
|
||||||
it again, and always *this* machine's token, so run it on the box rather than
|
machine's, so run it on the box. It needs no terminal and nobody logged in;
|
||||||
on the laptop.
|
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
|
||||||
It needs no terminal and no one logged in; hand it to systemd or launchd and
|
socket under its own state directory.
|
||||||
forget about it. Run the unit as the user who types the commands, or export the
|
|
||||||
same `SOULSEEK_STATE_DIR` in both, because a client looks for the socket under
|
|
||||||
its own state directory and finds nothing under someone else's.
|
|
||||||
|
|
||||||
**On your laptop**, say once where it lives:
|
**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>
|
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
|
Every command after that is the normal command, and the laptop needs no
|
||||||
laptop needs no Soulseek account of its own, because it uses the server's:
|
Soulseek account of its own:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs search 'aphex twin' --json
|
|
||||||
soulseek-rs get 'selected ambient works'
|
soulseek-rs get 'selected ambient works'
|
||||||
soulseek-rs daemon status # what it's doing right now
|
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
|
soulseek-rs daemon stop # shuts down the remote daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
One caution: there is no encryption on that TCP port. On a home network that
|
Downloads belong to the daemon, so closing the lid does not stop them. They
|
||||||
is usually fine. Over the internet, don't expose it. Reach it through SSH
|
still honour the *command's* deadline: a `download` or `get` reaching
|
||||||
instead, either with a tunnel (`ssh -L 5030:localhost:5030 nas`) or by running
|
`--timeout` (300s) tells the daemon to drop the transfer before it exits 5.
|
||||||
the commands over `ssh` on the box itself.
|
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
|
#### Build your own remote
|
||||||
|
|
||||||
The daemon's interface is open and documented, so nothing about it is specific
|
The daemon speaks newline-delimited JSON-RPC 2.0, described by an
|
||||||
to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described
|
[OpenRPC](https://open-rpc.org) document,
|
||||||
by an [OpenRPC](https://open-rpc.org) document,
|
[`docs/openrpc.json`](docs/openrpc.json), which it also serves over
|
||||||
[`docs/openrpc.json`](docs/openrpc.json). The daemon serves that document
|
`rpc.discover`. Point a client generator at it for typed bindings in your
|
||||||
itself via `rpc.discover`, so a client can ask a running daemon what it can
|
language. Searches, transfers, rooms, private messages and shares are all on
|
||||||
do.
|
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
|
Keep both ends on the same release; the handshake refuses mismatched protocol
|
||||||
that document and you get typed bindings in whatever language you are working
|
versions. The destination is session-wide, so `download.set_dir` moves it for
|
||||||
in. If you want an Android or iOS app that queues downloads on your home
|
every transfer and `download.start` ignores any directory the caller names.
|
||||||
server from the sofa, or a small web page that shows what is transferring, you
|
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the
|
||||||
have everything you need and no permission to ask for. Searches, transfers,
|
handshake, and how pushed events behave.
|
||||||
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.
|
|
||||||
|
|
||||||
### Scripting
|
### 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
|
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
|
||||||
```
|
```
|
||||||
|
|
||||||
`whoami`, `config` and `shares list|add|remove` are the ones a script runs
|
A script usually starts with `whoami`, which answers "are these credentials
|
||||||
first: the config commands need no account at all, and `whoami` answers
|
good and what am I offering" in one call.
|
||||||
"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
|
With a daemon attached every command reports the daemon's world: `--username`
|
||||||
borrows its session, so `--username` and `--password` go unread and `whoami`
|
and `--password` go unread, and `whoami` names the daemon's account, server and
|
||||||
names the daemon's account, server and download folder; pass `--no-daemon` when
|
download folder. `--no-daemon` tests this machine's own login instead.
|
||||||
you want to test this machine's own login. `shares list` stays local either
|
`shares list` reads the config file either way, while `shares status` and
|
||||||
way, because it reads the config file, while `shares status` and
|
`shares reindex` ask the session and report what peers can see.
|
||||||
`shares reindex` ask the session and so 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`,
|
`shares list|add|remove`, `wish add|remove|list`, `portmap`,
|
||||||
`skills install|uninstall|list`, `completions install|uninstall` and
|
`skills install|uninstall|list`, `completions install|uninstall` and
|
||||||
`daemon token|status|stop`. Most never touch the network at all;
|
`daemon token|status|stop`. Most never touch the network; `daemon status` and
|
||||||
`daemon status` and `daemon stop` talk to a daemon that is already logged in,
|
`daemon stop` talk to a daemon that is already logged in, so a machine with no
|
||||||
so a machine with no Soulseek account of its own can still ask whether one is
|
account of its own can still ask whether one is running. `shares status` and
|
||||||
running. `shares status` and `shares reindex` are not among them: they report
|
`shares reindex` are not among them, because reporting what the network sees
|
||||||
what the network will see, which means logging in and scanning the folders.
|
means logging in and scanning the folders.
|
||||||
|
|
||||||
Every command emits records except the six that perform an action.
|
Six commands perform an action and print nothing, answering with their exit
|
||||||
`room say`, `message send`, `shares add`, `shares remove`, `wish remove` and
|
code alone: `room say`, `message send`, `shares add`, `shares remove`,
|
||||||
`daemon stop` say nothing on stdout and answer with their exit code alone.
|
`wish remove` and `daemon stop`. `room listen`, `message read` and `serve`
|
||||||
`room listen`, `message read` and `serve` stream records as they arrive, until
|
stream records until `--duration` seconds pass or, with `--follow`, until
|
||||||
`--duration` seconds pass or, with `--follow`, until they are interrupted.
|
interrupted. The rest print records and exit.
|
||||||
|
|
||||||
#### Record shapes
|
#### 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
|
`completed`, `cancelled` or `failed`. `user`'s `status` is `online`, `away` or
|
||||||
`offline`, and any field the server did not answer prints as `-`.
|
`offline`, and any field the server did not answer prints as `-`.
|
||||||
|
|
||||||
`--json` carries more than text does: `search` adds `duration`, `slots`,
|
`--json` carries more than text does:
|
||||||
`speed` and `free_slot`; `browse` adds `directory`; `download` adds the `user`,
|
|
||||||
remote `path` and `size` alongside the local `file`; `whoami` adds `listening`,
|
| Command | Extra fields |
|
||||||
`listen_port`, `download_dir` and `privilege_seconds`; `user` adds `privileged`
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
and `shared_folders`; `shares status` adds the `directories` array; `serve`
|
| `search` | `duration`, `slots`, `speed`, `free_slot` |
|
||||||
adds `bytes_sent`, `size`, `speed` and a `reason` carrying the queue place or
|
| `browse` | `directory` |
|
||||||
the failure; `daemon status` adds `version`, `protocol`, `listening`,
|
| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` |
|
||||||
`listen_port`, `shared_folders`, `shared_files`, `download_dir` and
|
| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` |
|
||||||
`session_loss` (`displaced`, `disconnected`, or `null` while the session
|
| `user` | `privileged`, `shared_folders` |
|
||||||
holds); `portmap` adds `port`. Fields the server never answered are `null`
|
| `shares status` | the `directories` array |
|
||||||
rather than absent, so a missing reply cannot be misread as a zero.
|
| `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,
|
`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
|
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)
|
[](https://re-invention.nl/soulseek-rs/#agents)
|
||||||
|
|
||||||
Coding agents drive the surface above as readily as shell scripts do, but
|
`--help` lists flags but cannot say which JSON keys a record carries or what
|
||||||
`--help` cannot tell an agent which JSON keys a record carries or what exit 4
|
exit 4 means. That lives in a skill file shipped inside the binary:
|
||||||
means. That lives in a skill file shipped inside the binary, so `cargo install`
|
|
||||||
is all it takes to hand it over:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs skills install
|
soulseek-rs skills install
|
||||||
```
|
```
|
||||||
|
|
||||||
That writes `SKILL.md` into every agent it finds in your home directory,
|
It writes `SKILL.md` into every agent it finds in your home directory, today
|
||||||
currently Claude Code (`~/.claude/skills`) and opencode
|
Claude Code (`~/.claude/skills`) and opencode (`~/.config/opencode/skill`), and
|
||||||
(`~/.config/opencode/skill`), and prints one record per target:
|
prints one record per target:
|
||||||
|
|
||||||
```
|
```
|
||||||
installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md
|
installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md
|
||||||
unchanged opencode /home/you/.config/opencode/skill/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
|
Rerun it after upgrading the binary; an identical copy reports `unchanged`.
|
||||||
copy reports `unchanged` rather than rewriting it. `skills list` shows what is
|
`skills list` shows what is there, and `skills uninstall` removes only the file
|
||||||
there without touching anything, and `skills uninstall` removes it again, but
|
this binary wrote. For any other agent, name the directory yourself, or commit
|
||||||
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:
|
the skill next to a project so everyone working on it gets the same brief:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -490,18 +414,18 @@ soulseek-rs skills install --dir .claude/skills # commit it with the repo
|
|||||||
|
|
||||||
### Shell completions
|
### Shell completions
|
||||||
|
|
||||||
Tab completion is generated from the same `clap` definition the binary parses
|
Tab completion comes from the same `clap` definition the binary parses with, so
|
||||||
with, so it never drifts from `--help`:
|
it never drifts from `--help`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs completions install
|
soulseek-rs completions install
|
||||||
```
|
```
|
||||||
|
|
||||||
That covers whichever of bash, zsh and fish this machine uses, writing each
|
It covers whichever of bash, zsh and fish this machine uses, writing each script
|
||||||
script where that shell already looks — `~/.config/fish/completions` is scanned
|
where that shell already looks. `~/.config/fish/completions` is scanned
|
||||||
unprompted, while bash and zsh also get one marked `source` line appended to
|
unprompted; bash and zsh also get one marked `source` line appended to
|
||||||
`~/.bashrc` or `~/.zshrc`, because nothing else puts a per-user directory on
|
`~/.bashrc` or `~/.zshrc`, since nothing else puts a per-user directory on their
|
||||||
their search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
|
search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
|
||||||
|
|
||||||
```
|
```
|
||||||
installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs
|
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
|
### Sharing
|
||||||
|
|
||||||
`serve` is the mode that makes this client a source: it stays logged in, keeps
|
`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
|
the listener and share index alive, answers searches and browse requests, and
|
||||||
from the network, and prints one record per upload as it changes state
|
prints one record per upload as it changes state (`queued`, `uploading`,
|
||||||
(`queued`, `uploading`, `completed`, `cancelled`, `failed`). It ends after
|
`completed`, `cancelled`, `failed`). It ends after `--duration` seconds (an
|
||||||
`--duration` seconds (an hour by default), or runs until interrupted with
|
hour by default), or runs until interrupted with `--follow`.
|
||||||
`--follow`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs shares add ~/Music # remembered in config.toml
|
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
|
soulseek-rs serve --follow # add --upload-slots N to widen the queue
|
||||||
```
|
```
|
||||||
|
|
||||||
Uploads only show up inside a running `serve`; every other command is
|
Uploads only appear inside a running `serve`, since every other command is
|
||||||
short-lived with nothing to serve, so there is no `uploads list`. Managing
|
short-lived with nothing to serve, so there is no `uploads list`. Pausing,
|
||||||
transfers across commands (pause, resume, a queue that outlives one
|
resuming, and a queue that outlives one invocation are
|
||||||
invocation) is what [daemon mode](#daemon-mode) is for.
|
[daemon mode](#daemon-mode).
|
||||||
|
|
||||||
With a daemon running, `serve` starts no second server. It attaches and streams
|
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
|
the daemon's uploads, `--upload-slots` resizes the daemon's queue, and the local
|
||||||
local "nothing to share" and "needs the listener" checks are skipped, because
|
"nothing to share" and "needs the listener" checks are skipped because the
|
||||||
the daemon's shares and listener are the ones peers reach.
|
daemon's shares and listener are the ones peers reach.
|
||||||
|
|
||||||
Downloads run under a deadline (`--timeout`, 300s by default) and
|
Downloads run under `--timeout` (300s) and `--max-concurrent-downloads` at a
|
||||||
`--max-concurrent-downloads` at a time, so an unattended run always ends.
|
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
|
||||||
An interrupted download is picked up where it stopped. Bytes stream into
|
`download` asks the peer to start at the offset already on disk. Partial files
|
||||||
`<file>.part` as they arrive and it is renamed only once the file is whole, so
|
are never offered to peers, and deleting the `.part` starts over.
|
||||||
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
|
### 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 |
|
| `--no-daemon` | — | — | attach if one is running |
|
||||||
| `--log-file` | `SOULSEEK_LOG_FILE` | not a config key | stderr |
|
| `--log-file` | `SOULSEEK_LOG_FILE` | not a config key | stderr |
|
||||||
|
|
||||||
Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`.
|
Boolean environment variables accept `1`/`0`/`true`/`false`/`yes`/`no`.
|
||||||
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different config file and
|
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different file, `--no-config`
|
||||||
`--no-config` ignores it entirely, which is what an isolated or containerised
|
ignores it entirely, and `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate
|
||||||
run wants. The config file lives at `~/.config/soulseek-rs/config.toml` on
|
the config and state directories wholesale. The file lives at
|
||||||
macOS and Linux; `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the
|
`~/.config/soulseek-rs/config.toml` on macOS and Linux. Every variable above is
|
||||||
config and state directories wholesale. Every variable above is also read from
|
also read from a `.env` in the working directory, usually the tidiest way to
|
||||||
a `.env` file in the working directory, which is often the tidiest way to hand
|
hand a container its credentials.
|
||||||
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`,
|
(`username`, `server`, `listener_port`, `disable_listener`, `download_dir`,
|
||||||
`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd`,
|
`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd`,
|
||||||
`daemon`, `daemon_token`), and name the lot back at you when you ask for
|
`daemon`, `daemon_token`), and list them back at you when you name something
|
||||||
something else. Setting a key to
|
else. An empty string clears a key, and `shared_dirs` takes a comma-separated
|
||||||
an empty string clears it, and `shared_dirs` takes a comma-separated list.
|
list. Waits expressed in seconds (`--search-timeout`, `--timeout`,
|
||||||
Every wait expressed in seconds (`--search-timeout`, each `--timeout`, each
|
`--duration`) are bounded to one day, so a mistyped flag is rejected rather
|
||||||
`--duration`) is bounded to one day, so a mistyped flag is rejected rather than
|
than hanging.
|
||||||
hanging until the heat death of the universe.
|
|
||||||
|
|
||||||
#### Credentials for unattended runs
|
#### 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.
|
raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given.
|
||||||
`NO_COLOR` and a non-terminal stderr both disable colour.
|
`NO_COLOR` and a non-terminal stderr both disable colour.
|
||||||
|
|
||||||
### Private messages
|
### Chat and messages
|
||||||
|
|
||||||
Send and read private messages from the command line:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs message send <username> "hello there"
|
soulseek-rs message send <username> "hello there"
|
||||||
soulseek-rs message read --duration 60 # or --follow to run until killed
|
soulseek-rs message read --duration 60 # or --follow 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 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
|
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
|
### Connectivity
|
||||||
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 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
|
- The port is `--listener-port` (`SOULSEEK_LISTENER_PORT`, default `2234`).
|
||||||
incoming connection. When the listener is enabled (the default), the client
|
Forward that TCP port when auto-mapping cannot get it.
|
||||||
tries to open its listen port on your router via **UPnP-IGD** and **NAT-PMP**,
|
- `--no-listener` turns the listener and port mapping off; `--listener` forces
|
||||||
so firewalled peers and the server can connect back. This is best-effort: if
|
it on when the config file disables it.
|
||||||
your router has UPnP/NAT-PMP disabled it's a no-op, and a log line tells you to
|
- When another process already holds the port, the usual case with several
|
||||||
forward the port yourself.
|
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.
|
||||||
- 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
soulseek-rs portmap # exits 0 when mapping works, 4 when it does not
|
soulseek-rs portmap # exits 0 when mapping works, 4 when it does not
|
||||||
soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"}
|
soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"}
|
||||||
```
|
```
|
||||||
|
|
||||||
It tries to open the port via UPnP/NAT-PMP, reports whether your router allowed
|
`portmap` opens the port, reports whether the router allowed it and your
|
||||||
it (and your external address), then removes the test mapping. Because the
|
external address, then removes the test mapping. The verdict is the exit code,
|
||||||
verdict is in the exit code, `soulseek-rs portmap || notify-me` works as a
|
so `soulseek-rs portmap || notify-me` works as a health check.
|
||||||
health check.
|
|
||||||
|
|
||||||
If both you and a peer are behind routers with no forwarded port, browsing that
|
Two peers both behind routers with no forwarded port cannot browse each other.
|
||||||
peer can't work. That's a fundamental Soulseek/peer-to-peer limitation, not a
|
That is a Soulseek limitation, not a bug.
|
||||||
bug.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -696,13 +584,11 @@ cargo fmt
|
|||||||
|
|
||||||
### End-to-end tests
|
### End-to-end tests
|
||||||
|
|
||||||
Two suites exercise the client against a real Soulseek server using
|
Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a
|
||||||
[soulfind](https://github.com/soulfind-dev/soulfind), a local server
|
local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
|
||||||
implementation: `soulseek-rs-lib/tests/e2e.rs` covers the protocol library, and
|
library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
|
||||||
`soulseek-rs/tests/cli_e2e.rs` drives the actual binary the way a script would
|
would. Both are **server-optional**, running when a server is available and
|
||||||
(records on stdout, exit codes, real searches, downloads, and chat). The tests
|
skipping otherwise, so `cargo test` stays green everywhere.
|
||||||
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:
|
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.
|
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
|
**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
|
network of other people's computers, and you arrive on it under the user's one
|
||||||
account**. Without a daemon, every invocation is its own login, and running two
|
account. That name is their standing: peers see what it shares, add it to their
|
||||||
at once leaves you two bad choices: share a username and they silently cut each
|
lists, and queue it again later. The server allows **one session per account**,
|
||||||
other off (exit 7), or give each run a throwaway name and *register a new
|
so without a daemon every invocation is its own login and two at once silently
|
||||||
account on a public server for every search you make*. The second is what a
|
cut each other off (exit 7). Never work around that by inventing usernames — an
|
||||||
naive script does, and it is the antisocial one: account churn on
|
account per command is churn on infrastructure other people maintain, and it
|
||||||
infrastructure other people maintain and share.
|
throws away the standing the user built.
|
||||||
|
|
||||||
A daemon is one login for as long as you work, however many commands you run
|
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
|
against it. It also keeps the user's shared folders indexed and reachable
|
||||||
them on every invocation, and downloads it starts outlive the command that
|
instead of re-scanning them on every invocation, and downloads it starts
|
||||||
asked for them, so a transfer survives the process that queued it.
|
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`
|
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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Commands
|
||||||
|
|
||||||
@@ -481,9 +482,9 @@ transfers on one person.
|
|||||||
or any fetch whose size you do not know.
|
or any fetch whose size you do not know.
|
||||||
|
|
||||||
Twenty at once works only because the daemon holds the session: twenty commands
|
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),
|
sharing one login. Without a daemon they displace each other (exit 7). Do not
|
||||||
and giving each its own `--username` would register a throwaway account per
|
reach for `--username` to get around it: that registers an account per track on
|
||||||
track, which is worse. Start the daemon first.
|
someone else's server. Start the daemon first.
|
||||||
|
|
||||||
Keep the listener on: a peer that cannot connect back to you cannot send you
|
Keep the listener on: a peer that cannot connect back to you cannot send you
|
||||||
the file.
|
the file.
|
||||||
|
|||||||
Reference in New Issue
Block a user