Commit Graph
7 Commits
Author SHA1 Message Date
Michel de Graaf a69f52c657 test(daemon): drive the whole CLI suite against a daemon, and fix what it caught
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.

Two defects it found:

serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.

The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.

main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
2026-07-27 10:58:27 +02:00
Michel de Graaf a0354f66a8 feat(daemon): route commands and the TUI through a running daemon
Session::open attaches to a daemon when one is reachable and logs in
otherwise, so every existing command works unchanged against either. A local
socket is found automatically, which is what makes an agent's invocation need
no flags at all; --no-daemon opts out.

Commands now read identity, shares, server, and download directory from the
session rather than from this run's config: routed to a daemon those differ,
and the session is the one actually on the network. Without that, whoami
named the wrong server and shares reindex would have imposed the client
machine's folders on the daemon.
2026-07-27 10:33:41 +02:00
Michel de Graaf e2813392ea refactor(api): put a SessionApi seam between the commands, the TUI, and the client
Commands and the TUI now hold an Arc<dyn SessionApi> instead of an
Arc<Client>. The trait is implemented on Client itself, so the local path
is the same client behind one vtable and no wrapper type exists to keep in
sync. Daemon mode plugs a remote implementation into the same handle.
2026-07-27 09:48:46 +02:00
Michel de Graaf db75a8266a fix(serve): report a queued upload even when it is served between two polls
`serve` samples `uploads()` every 500ms and emits what changed, so an upload
state shorter than that window never reached the log. Nothing surfaced this
while every upload sat on its slot for a 500ms linger, which was exactly one
poll; with the linger gone a peer can queue and be served between two samples,
and "it had to wait" — the one thing the queue exists to record — silently
disappeared.

The pump now keeps whatever it left waiting, and `serve` drains that alongside
the snapshot, so the fact survives regardless of when the next sample lands.
The list is deduplicated per peer and file and capped, so a caller that never
drains cannot grow it without bound.

This is what made cli_e2e::serve_reports_a_queued_upload_once_the_slots_are_full
fail on CI while passing locally: the assertion was real, and it was timing that
decided whether the client could answer it.
2026-07-26 18:54:40 +02:00
Michel de Graaf e8a48c6b06 fix!: make concurrent invocations work instead of silently seeing nothing (#22)
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:

- The peer listener bound its port inside a detached thread and panicked on
  EADDRINUSE, unwinding that thread alone. The process carried on with no
  listener, so no peer could deliver a search response. `Listen::bind` now takes
  the socket before login and falls back to an ephemeral port when the
  configured one is held.
- SetWaitPort advertised the configured port whether or not it was bound, so
  peers dialled a sibling process that discarded responses to tickets it never
  issued. The port the listener really holds is what gets advertised, mapped
  through UPnP, and reported by `whoami` and `serve`.
- A zero-byte read is EOF on a TCP socket, but MessageReader reported it as
  success, so a server that closed cleanly left the actor polling a dead socket.
  It is now an error, and Relogged (code 41) has a handler. The peer actor
  classifies that error: a control connection that was established and then
  closed is a clean disconnect, because peers drop idle sockets while the bytes
  move over their own connection, and reporting an error there fails every
  download queued with that peer including the one streaming. A connection that
  never established keeps the error path, which is what the server-brokered
  fallback reads to know a peer is unreachable.
- A session the server cut off - usually because another process logged in under
  the same name - exited 4, documented as "it worked and found nothing". Callers
  concluded the file was unavailable. Sessions now record why they ended and
  those verdicts become exit 7.

The server allows one session per account, so concurrency still needs a distinct
--username per run; SKILL.md documents that. The listener port needs no such
care any more.

Six e2e tests against soulfind cover it, each of which fails without the fix: a
taken listen port, a displaced session, three clients searching at once under
one configured port, the two CLI equivalents, and a peer that hangs up its
control connection while a transfer is still to come.

BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
2026-07-26 14:52:31 +02:00
Michel de Graaf 9b87bd6a0f feat!: wishlist, upload-queue privilege recognition, and search filters (#20)
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.

Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.

Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.

Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.

Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
2026-07-26 14:20:07 +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