diff --git a/README.md b/README.md index ff1487e..8cccc1a 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ music enthusiasts around the world to share niche music. ## Features -- **Search & download** — search the network, pick results in the TUI, and - queue downloads with pause, resume, and retry -- **Sharing** — point `--shared-dir` at a directory and your files show up in - searches; peers can browse and download them +- **Search & download** — search the network and queue downloads in the TUI, + or fetch a track in one command with `get` +- **Sharing** — `shares add` a directory and your files show up in searches; + `serve` stays online so peers can browse and download them - **Browse** — list any user's shared files and download straight from the tree - **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 @@ -136,16 +136,25 @@ soulseek-rs user # a peer's status and share counts soulseek-rs shares list|add|remove|status|reindex soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping +soulseek-rs skills install|uninstall|list # teach a coding agent this CLI ``` `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. -Every command emits records except the two that perform an action — -`room say` and `message send` say nothing and answer with their exit code -alone. `room listen` and `message read` stream records as they arrive, until -`--duration` seconds pass or, with `--follow`, until they are interrupted. +Eleven of these need no credentials, because they never touch the network: +`config path|list|get|set`, `shares list|add|remove`, `portmap` and +`skills install|uninstall|list`. Note that +`shares status` and `shares reindex` are not among them — they report what the +network will actually see, which means logging in and letting the library scan +the folders. + +Every command emits records except the four that perform an action — +`room say`, `message send`, `shares add` and `shares remove` say nothing on +stdout and answer with their exit code alone. `room listen`, `message read` +and `serve` stream records as they arrive, until `--duration` seconds pass or, +with `--follow`, until they are interrupted. #### Record shapes @@ -154,12 +163,37 @@ a path may contain anything except a tab (tabs and newlines in peer-supplied text are replaced with spaces so a record is always exactly one line): ``` -search user size bitrate path -browse user size path -room users room -download local-path +search user size bitrate path +browse user size path +download / get local-path +room list users room +room users user +room listen type room user message +message read timestamp user message +serve status user sent/size path +user user status average-speed shared-files +whoami user server shared-folders shared-files +shares list ok|missing directory +shares status folders files +config get|set|list|path key value +portmap ok|failed backend external ``` +`room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves +the message field empty. `serve`'s `status` is `uploading`, `completed`, +`cancelled` or `failed`. `user`'s `status` is `online`, `away` or `offline`, +and any field the server did not answer prints as `-`. + +`--json` carries more than text does, because an object has room for fields a +column layout does not: `search` adds `duration`, `slots`, `speed` and +`free_slot`; `browse` adds `directory`; `download` adds the `user`, remote +`path` and `size` alongside the local `file`; `whoami` adds `listening`, +`listen_port` and `download_dir`; `user` adds `privileged` and +`shared_folders`; `shares status` adds the `directories` array; `serve` adds +`bytes_sent`, `size`, `speed` and a `reason` on failure; `portmap` adds +`port`. Fields the server never answered are `null` rather than absent, so a +missing reply cannot be misread as a zero. + `download --stdin` reads back either shape it emits — a JSON object per line, or tab-separated text whose first field is the user and last field is the path — so `search` and `browse` pipe straight into it. @@ -199,13 +233,46 @@ soulseek-rs serve --follow --json | tee -a uploads.ndjson soulseek-rs user someuser --json | jq -e '.status != "offline"' ``` +### Agent skills + +The scriptable surface above is meant to be driven by coding agents as much as +by shell scripts, 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 a `cargo install` is all you need to hand it to your agent: + +```bash +soulseek-rs skills install +``` + +That writes `SKILL.md` into every agent it finds in your home directory — +currently Claude Code (`~/.claude/skills`) and opencode +(`~/.config/opencode/skill`) — and prints one record per target: + +``` +installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md +unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md +``` + +Running it again is how you update after upgrading the binary; an identical +copy reports `unchanged` rather than rewriting it. `skills list` shows what is +there without touching anything, and `skills uninstall` removes it again — +only when the file it finds is the one this binary wrote. + +For an agent this table does not cover, name the directory yourself, or commit +the skill next to a project so everyone working on it gets the same brief: + +```bash +soulseek-rs skills install --dir ~/.config/some-agent/skills +soulseek-rs skills install --dir .claude/skills # commit it with the repo +``` + ### Sharing `serve` is the mode that makes this client a source: it stays logged in, keeps the listener and the share index alive, answers searches and browse requests from the network, and prints one record per upload as it changes state (`uploading`, `completed`, `cancelled`, `failed`). It ends after `--duration` -seconds, or runs until interrupted with `--follow`. +seconds (an hour by default), or runs until interrupted with `--follow`. ```bash soulseek-rs shares add ~/Music # remembered in config.toml @@ -228,26 +295,37 @@ Downloads run under a deadline (`--timeout`, 300s by default) and Every setting can come from a flag, an environment variable, or the config file, in that order of precedence. Flags work before or after the subcommand. -| Flag | Environment variable | `config.toml` key | -| ---- | -------------------- | ----------------- | -| `--username` | `SOULSEEK_USERNAME` | `username` | -| `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | -| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | -| `--server` | `SOULSEEK_SERVER` | `server` | -| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | -| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | -| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | -| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | -| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | -| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | -| `--log-file` | `SOULSEEK_LOG_FILE` | — | +| Flag | Environment variable | `config.toml` key | Default | +| ---- | -------------------- | ----------------- | ------- | +| `--username` | `SOULSEEK_USERNAME` | `username` | — (required) | +| `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | — (required) | +| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | — | +| `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` | +| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `/Soulseek` | +| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir | +| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | +| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | +| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `5` | +| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | +| `--log-file` | `SOULSEEK_LOG_FILE` | — | stderr | Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`. `--config ` (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. +config and state directories wholesale. Every variable above is also read from +a `.env` file in the working directory, which is often the tidiest way to hand +a container its credentials. + +`config get` and `config set` cover the nine settings the file can hold — +`username`, `server`, `listener_port`, `disable_listener`, `download_dir`, +`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd` — +and name the lot back at you when you ask for something else. Setting a key to +an empty string clears it, and `shared_dirs` takes a comma-separated list. +Every wait expressed in seconds (`--search-timeout`, each `--timeout`, each +`--duration`) is bounded to one day, so a mistyped flag is rejected rather than +hanging until the heat death of the universe. #### Credentials for unattended runs diff --git a/soulseek-rs-lib/README.md b/soulseek-rs-lib/README.md index 697bc85..4a7f937 100644 --- a/soulseek-rs-lib/README.md +++ b/soulseek-rs-lib/README.md @@ -28,7 +28,7 @@ fn main() -> Result<(), Box> { // Create and connect to Soulseek server let mut client = Client::new("username", "password"); - client.connect(); + client.connect()?; client.login()?; // Search for files @@ -37,13 +37,18 @@ fn main() -> Result<(), Box> { // Download first available file if let Some(result) = results.iter().find(|r| !r.files.is_empty()) { let file = &result.files[0]; - client.download( + let (_download, status) = client.download( file.name.clone(), file.username.clone(), file.size, "~/Downloads".to_string(), )?; - println!("Downloaded: {}", file.name); + + // `download` returns as soon as the request is queued: the transfer is + // finished when the channel says so, not when this call returns. + for update in status { + println!("{}: {update:?}", file.name); + } } Ok(()) @@ -58,16 +63,56 @@ use soulseek_rs::{Client, ClientSettings, PeerAddress}; fn main() -> Result<(), Box> { // Create client with custom settings let settings = ClientSettings { - server_address: PeerAddress::new("server.slsknet.org".to_string(), 2242), + server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416), enable_listen: true, listen_port: 3000, ..ClientSettings::new("username", "password") }; let mut client = Client::with_settings(settings); - client.connect(); + client.connect()?; client.login()?; Ok(()) } ``` + +### Asking about a peer + +The server-backed lookups are request/poll pairs: you ask, then read the +answer once it lands. `request_user_info` clears any previous answer first, so +the loop below observes this request rather than the last one. + +```rust +use soulseek_rs::Client; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +fn main() -> Result<(), Box> { + let mut client = Client::new("username", "password"); + client.connect()?; + client.login()?; + + client.request_user_info("someuser")?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Some(info) = client.user_info("someuser") { + // Presence and stats are separate replies, so each is an Option: + // a reply that never arrived is None, not "offline" or "shares nothing". + println!("{:?} {:?}", info.presence, info.stats); + break; + } + sleep(Duration::from_millis(100)); + } + + // Room membership is kept current from join and leave events. + println!("{:?}", client.room_members("lobby")); + + Ok(()) +} +``` + +`ServerMessage` and `ClientOperation` are `#[non_exhaustive]`: every protocol +message the client learns to handle adds a variant, so match them with a +wildcard arm. diff --git a/soulseek-rs/README.md b/soulseek-rs/README.md index 06b2f50..1a01093 100644 --- a/soulseek-rs/README.md +++ b/soulseek-rs/README.md @@ -1,26 +1,66 @@ # soulseek-rs -A command-line client for the Soulseek peer-to-peer network. +A Soulseek client for the terminal: a full-screen TUI, and a one-shot command +surface built for scripts and agents. ## Installation -`bash cargo install soulseek-rs ` - -This will install the `soulseek-rs` binary to your system. +```bash +cargo install soulseek-rs +``` ## Usage -The client provides a simple interface to search and download files from the -Soulseek network. +Run it with no subcommand for the interactive TUI: + +```bash +soulseek-rs +``` + +Anything else is a one-shot command that runs headless. stdout carries records +only, stderr carries progress, and the exit code carries the verdict: + +```bash +soulseek-rs search # print matching files +soulseek-rs get # search, pick the best, download +soulseek-rs download # fetch one known file +soulseek-rs download --stdin # fetch files listed on stdin +soulseek-rs browse # list a user's shared files +soulseek-rs serve [--follow] # stay online sharing, stream uploads +soulseek-rs whoami # confirm credentials and connection +soulseek-rs user # a peer's status and share counts +soulseek-rs room list|say|users|listen +soulseek-rs message send|read +soulseek-rs shares list|add|remove|status|reindex +soulseek-rs config path|list|get|set +soulseek-rs portmap # test automatic port mapping +``` + +Add `--json` for newline-delimited JSON instead of tab-separated text, so +commands compose: + +```bash +soulseek-rs search "rjd2" --json \ + | jq -c 'select(.size > 5000000 and .free_slot)' \ + | soulseek-rs download --stdin +``` + +Exit codes: `0` success, `2` usage or config, `3` connect or login, `4` nothing +found, `5` timed out, `6` transfer failed, `1` unexpected error. + +The [full README](https://github.com/michel/soulseek-rs#readme) documents every +command, record shape, flag, environment variable and `config.toml` key. ## Building from Source -````bash git clone https://github.com/yourusername/soulseek-rs cd soulseek-rs -cargo build --release ``` +```bash +git clone https://github.com/michel/soulseek-rs.git +cd soulseek-rs +cargo build --release +``` ## Library -If you want to build your own Soulseek client, check out the -[soulseek-rs-lib](https://crates.io/crates/soulseek-rs-lib) library crate. - -```` +To build your own Soulseek client, use the +[soulseek-rs-lib](https://crates.io/crates/soulseek-rs-lib) crate: the protocol +implementation this client is built on.