Commit Graph
38 Commits
Author SHA1 Message Date
Michel de Graaf d2f3dd13d7 feat: log in with soulseek-rs's own client major version (176) (#13)
The Login message hardcoded SoulseekQt's major version 157, so the
server could not tell soulseek-rs apart from the official client. The
protocol document now reserves a unique major version per project, so
claim 176 and send it via a new ClientVersion on ClientSettings.

Clients and bots built on the library keep major 176 and pick their own
minor version (default 1); the reference CLI/TUI logs in with minor 100.

Refs #12
2026-07-25 21:51:58 +02:00
Michel de Graaf 95bc18672d feat(cli): ship the agent skill inside the binary
A `cargo install` leaves a binary and nothing else, so there is no file on
disk for a coding agent to read. `skills install` writes an embedded SKILL.md
into every agent found on the machine — Claude Code and opencode today, any
other via --dir — and installing over an older copy is what updating means.

The skill carries what --help cannot: the JSON keys of every record, what each
exit code means, and the rule that an agent always passes --json. A unit test
walks the clap command tree and holds SKILL.md to a documented entry for every
command, so a new subcommand fails the build until it is written up.
2026-07-25 02:17:11 +02:00
Michel de Graaf 03e8924a6b feat(cli): expose sharing, peer lookup and settings as one-shot commands
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
2026-07-25 01:30:48 +02:00
Michel de Graaf be6581ee6c feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.

The commands are now built for scripts and agents:

  search  download  get  browse  room list|say|listen  message send|read

stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.

Defects found and fixed along the way, each covered by a test:

- a download with an unknown size was finalized against zero bytes and saved
  as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
  peers shadowed itself and two files sharing a basename overwrote each
  other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
  wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
  server went quiet

Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
2026-07-25 01:30:48 +02:00
michel a605431a10 feat: multiple shared directories
Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
2026-07-20 20:47:23 +02:00
michel 8389d525e7 feat: conventional default download and share folders
The default download directory is now the platform Downloads folder plus
a Soulseek subfolder (via the directories crate — XDG on Linux, known
folder on Windows) instead of a tilde path that only worked with a HOME
variable. Sharing follows the Soulseek convention of sharing what you
download: shared_dir defaults to the download folder, and an explicitly
empty shared_dir opts out. The folder is created at startup so first-run
share validation succeeds.
2026-07-20 20:43:27 +02:00
michel 36819d4c42 test: persistence e2e and registration-relogin e2e; lint clean
Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
2026-07-20 20:20:54 +02:00
michel c945bd5d5e feat: persist and restore TUI state across restarts
The main TUI now loads state on startup — search history restored as
inert entries, saved chat rooms rejoined on the server, and incomplete
downloads (including failed/timed-out ones) automatically re-enqueued —
and saves a diffed snapshot each loop tick and on exit, so only changed
files are rewritten.
2026-07-20 20:17:44 +02:00
michel d913c5cc71 feat: in-TUI login/registration screen
The default TUI path now brings the terminal up first and runs a login
screen whenever no working credentials exist: a pure-state-machine form
(unit tested) driven by an async connect/login attempt on a background
thread. Soulseek registers unknown usernames on login, so the same form
covers registration. On success the username is saved to config.toml and
a form-entered password to the OS keychain; stored credentials skip the
form entirely. Failed logins show the server error and retry instead of
aborting.
2026-07-20 20:14:17 +02:00
michel aaeb62efa4 feat: password resolution via OS keychain and password_cmd
SecretStore trait with a keyring-backed implementation (macOS Keychain,
Windows Credential Manager, Linux Secret Service) under service
soulseek-rs. Precedence: --password/env > keychain > password_cmd from
config.toml. Keychain failures degrade to not-found so headless boxes
fall through to password_cmd.
2026-07-20 20:11:41 +02:00
michel 86e3d8c58f feat: config.toml settings layer (CLI > env > file > defaults)
New persist module in the binary crate (lib stays zero-dep): FileConfig
loaded from a platform-appropriate config path via the directories crate,
with resolution precedence CLI/env (clap) > config.toml > built-in
defaults. Clap defaults move into the resolver so the file layer can sit
between.
2026-07-20 20:10:33 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00
michel 2d5fc26db7 test(net): e2e-test the UPnP path + add a portmap diagnostic
Address the gap that the port mapper's UPnP path and gateway discovery
had no automated coverage (only NAT-PMP did):

- UPnP e2e: split map_via_gateway out of discovery and drive it through
  igd-next against an in-process mock IGD (a std HTTP server returning a
  canned SOAP envelope). Verifies the real add_port SOAP round-trip and
  that we request the exact external/internal port + LAN client.
- default_gateway parsing is now pure, testable functions with unit
  tests for the macOS and Linux formats (including the 0.0.0.0 skip).
- New 'portmap' CLI subcommand (needs no login): synchronously probes
  UPnP/NAT-PMP against the real router and reports whether the port was
  opened + the external address, then removes the test mapping — so a
  user can verify reachability on their own network.

9 port_mapping tests now cover the NAT-PMP protocol, the UPnP SOAP
round-trip, gateway parsing, and the map/renew/cleanup lifecycle.
2026-07-19 21:46:46 +02:00
michel 5ba2f9e181 feat(net): auto-map the listen port via UPnP-IGD and NAT-PMP
Behind a home router the client is unreachable for incoming peer
connections, so browsing/downloading firewalled peers (and the
server-brokered connect-back) times out. Add a best-effort port mapper
to the binary crate that, when listening is enabled, makes us reachable:

- NAT-PMP (RFC 6886) hand-rolled on std::net::UdpSocket, zero deps, with
  an injectable gateway address so it is fully testable.
- UPnP-IGD via the blocking igd-next crate as the primary path.
- A background PortMapper that discovers off the main thread (never
  blocks startup), renews at half the lease, and removes the mapping on
  drop. Any failure is non-fatal and logged.
- Maps the EXACT listen port only (we advertise it verbatim via
  SetWaitPort, so a different external port would be unreachable) and
  warns the user to forward manually otherwise.

Wired into the TUI, search, and CLI browse (the P2P modes); chat/rooms/
messaging are server-mediated and skip it. e2e-tested against an
in-process mock NAT-PMP gateway: the map request bytes, response parsing,
result-code/timeout handling, and the full spawn -> map -> drop -> unmap
service lifecycle.
2026-07-19 21:10:15 +02:00
michel 7d94b2caa3 feat(cli): rooms and chat subcommands
Add `rooms` (list public rooms with user counts, busiest first) and
`chat <room> [message]` (join a room; say a message and exit, or listen
and print incoming messages/joins/leaves for a while). Factor a shared
connect_and_login helper.
2026-07-19 19:49:17 +02:00
michel 7d8a435d25 feat(cli): add 'browse <username>' to list a user's shared files
soulseek-rs browse <username> connects, logs in, requests the peer's file
list and prints the directories and files it shares.
2026-07-19 18:57:14 +02:00
michel 05b06ec2ec feat(tui): compose and read private messages
Add private messaging to the TUI:
- 'm' opens the command bar in message mode; type '<recipient> <text>' and
  Enter sends it via the client;
- incoming messages are polled each tick into an inbox;
- 'i' toggles a centered inbox popup listing sent/received messages.

The shared command bar now carries a mode (search vs message) so the prefix,
submit action and shortcut hints follow what is being typed.
2026-07-19 13:38:36 +02:00
michel f6127411d7 feat(cli): add 'message' subcommand to send private messages
`soulseek-rs message <username> <text>` connects, logs in, sends a private
message and exits. Smoke-tested against a local soulfind server.
2026-07-19 13:32:53 +02:00
michel a127dd97bf ci: run soulfind e2e tests on GitHub Actions
Extend the existing CI workflow with an ubuntu-only "End-to-end (soulfind)"
job that builds soulfind from source (LDC + `dub build :server`), points
SOULFIND_BIN at it, and runs the e2e suite with SOULSEEK_E2E_REQUIRED=1.
Also widen the clippy job to the whole workspace.

The e2e harness gains SOULSEEK_E2E_REQUIRED: when set, a missing/unstartable
server is a hard failure instead of a silent skip, so CI genuinely exercises
the client end-to-end. Add e2e coverage for two existing behaviours a single
server can verify: wrong-password rejection and concurrent logins.
2026-07-19 13:19:46 +02:00
michel 1328736550 feat: configure download & upload (shared) directories
Add a --shared-dir / SOULSEEK_SHARED_DIR option and a SOULSEEK_DOWNLOAD_DIR
env for the download directory. The shared directory is resolved and
validated up front (tilde-expanded, must exist and be a directory); a
misconfigured value is a warning, not a fatal error.

The library gains an optional shared_directory on ClientSettings/Client
with a getter, keeping soulseek-rs-lib dependency-free. Resolution lives
in a new soulseek-rs/src/directories.rs with unit tests.
2026-07-19 13:12:44 +02:00
michel e72c8a5c07 style: crank clippy to pedantic+nursery and fix all lints
Enable the full clippy all + pedantic + nursery groups as workspace warnings,
with a small documented allow-list for the pedantic/nursery lints this project
intentionally does not follow (rustdoc-style requirements, deliberate numeric
casts in the protocol/UI layers, and a few noisy nursery lints). Also enable
the rust unused_lifetimes and unsafe_op_in_unsafe_fn lints.

Fixed every resulting warning (527 -> 0) mechanically per clippy's suggestions:
inline format args, const fns, item ordering, let-else, redundant clones,
map_or, cast_lossless, Eq derives, associated-fn conversions for methods that
never used self, and removing unnecessary Result wrappers whose call sites could
never propagate an error. No runtime behavior changed; cargo fmt applied.

cargo clippy --workspace --all-targets: 0 warnings. All tests pass.
2026-07-19 00:09:17 +02:00
michel 2722552e35 refactor: replace lock unwraps with poison-aware helpers
Add SoulseekRs::LockPoisoned variant and a small RwLockExt/MutexExt
helper module exposing read_safe/write_safe/lock_safe that map
PoisonError to that variant. Walk every non-test lock acquisition
across client, peer/server actors, peer registry, listener and the
thread pool: propagate via ? where the function returns Result, log
and degrade to a sensible default (return early, false, None, empty)
where it does not. Client::connect now returns Result so the CLI can
surface the failure instead of panicking.
2026-04-26 12:32:46 +02:00
Ariel D'Alessandro 3696e95760 soulseek-rs: main: Add support for dotenv environment
Signed-off-by: Ariel D'Alessandro <ariel.dalessandro@collabora.com>
2026-01-29 19:36:15 -03:00
Michel de Graaf dfe6f532fa chore: formatting 2026-01-04 20:37:03 +01:00
mayor 16c8a4bde9 chore: update to Rust 2024 edition 2026-01-04 20:08:16 +01:00
Michel de Graaf 2ba48663e4 feat: logging to log file 2025-11-18 15:49:53 +01:00
Michel de Graaf 9a4ad6c38a feat: main TUI 2025-11-13 14:12:37 +01:00
Michel de Graaf cafa40a09d chore: refactor to support multi search 2025-11-13 10:28:16 +01:00
Michel de Graaf 69a044b1fa chore: UI tweaks 2025-11-12 14:52:31 +01:00
Michel de Graaf e87853c66a chore: bump version 2025-11-12 02:03:17 +01:00
Michel de Graaf 5816d9b632 chore: refactor CLI app with new modules for seperation of concerns, reusable components and consistent styling 2025-11-12 02:02:35 +01:00
Michel de Graaf 5a28452c02 feat: clear filter in multi-select download screen function 2025-11-12 01:52:27 +01:00
Michel de Graaf 689e667ade chore: bump version 2025-11-12 01:41:43 +01:00
Michel de Graaf 3f506389f0 feat: improved search screen 2025-11-12 01:37:08 +01:00
Michel de Graaf 30c4fd2aad feat: better search TUI, multi select downloads and parallel downloads 2025-11-12 00:26:03 +01:00
Michel de Graaf 98a8f53094 feat: improved cli 2025-11-11 23:38:59 +01:00
Michel de Graaf 3d6cc9f7cd feat: client settings and defaults 2025-11-11 22:22:21 +01:00
Michel de Graaf 7bf2748e1f feat: seperate into lib and cli packages 2025-11-11 21:55:53 +01:00