Commit Graph
403 Commits
Author SHA1 Message Date
Michel de Graaf 196fdbcf71 fix(lib): report a download's rate from the bytes actually read
Progress assumed every read had filled the buffer, so a peer trickling
small chunks was reported at up to several times its real rate. The
rate now comes from the bytes received since the last report.
2026-09-07 15:47:22 +02:00
Michel de Graaf 761eb7d6a1 Merge pull request #70 from michel/lib/server-keepalive
fix(lib): keep the server connection alive with TCP keepalive
2026-09-07 15:39:05 +02:00
Michel de Graaf 662d40b79c fix(lib): keep the server connection alive with TCP keepalive
A NAT that drops an idle mapping leaves the server socket looking
connected while the server can no longer reach us, and nothing errors:
peers stop finding us and searches go unanswered until a restart. The
server socket now carries TCP keepalive (idle 10s, interval 2s, ten
probes), which keeps the mapping alive and turns a dead path into a read
error within about half a minute, as Nicotine+ does. The server stopped
answering ServerPing (code 32) long ago, so an application ping is no
substitute. Windows gets a no-op until the daemon ships there.
2026-09-07 15:39:00 +02:00
Michel de Graaf 7bb08fd66d Merge pull request #69 from michel/lib/user-info
feat(lib): answer UserInfoRequest with our slots and queue
2026-09-07 15:32:57 +02:00
Michel de Graaf 73208ae927 feat(lib): answer UserInfoRequest with our slots and queue
Opening a user's profile in SoulseekQt or Nicotine+ asks that peer
directly (peer code 15), and a peer that never answers shows an endless
spinner. The reply (code 16) carries no description or picture, the
configured upload slot count, how many uploads are waiting, and whether a
slot is free right now, which is what the reference clients send.
2026-09-07 15:32:30 +02:00
Michel de Graaf 567e942252 Merge pull request #67 from michel/test/stress-mock-bind
test(stress): bind the mock peers on all interfaces
2026-09-07 15:32:00 +02:00
Michel de Graaf fe9631e264 Merge pull request #68 from michel/lib/search-reply-cap-300
fix(lib): raise the search reply cap to 300 files
2026-09-07 15:29:06 +02:00
Michel de Graaf e5d240a708 fix(lib): raise the search reply cap to 300 files
The cap of 150 was attributed to Nicotine+, whose default is in fact
300 (slskd's is 500). A reply now carries up to 300 matches.
2026-09-07 15:27:01 +02:00
Michel de Graaf ce621e0d0d test(stress): bind the mock peers on all interfaces
soulfind has reported the host's LAN address for a loopback login since
1eab2e8 (November 2025), so the client dials the mocks there and a
listener bound to 127.0.0.1 refuses it: every upload and most downloads
failed against a current soulfind while the e2e suite, whose mocks
already bind all interfaces, stayed green.
2026-09-07 15:24:55 +02:00
Michel de Graaf 7c4d9e2c9a Merge pull request #66 from michel/lib/folder-contents
feat(lib): answer FolderContentsRequest with the folder's listing
2026-09-07 15:24:34 +02:00
Michel de Graaf 4839e36be8 feat(lib): answer FolderContentsRequest with the folder's listing
"Download folder" in SoulseekQt and Nicotine+ asks the sharer for that
one folder (peer code 36) instead of the whole share, and a sharer that
never answers leaves the folder download hanging. The requested folder
now comes back as a FolderContentsResponse (code 37), which carries a
directory listing in the same form a browse does, so the two share one
codec. The browse and folder replies also share one reply helper.
2026-09-07 15:21:42 +02:00
Michel de Graaf 53b32e0201 Merge pull request #65 from michel/lib/honest-search-reply
fix(lib): advertise real slot, speed and queue figures in search replies
2026-09-07 15:16:33 +02:00
Michel de Graaf 352816cc1e fix(lib): advertise real slot, speed and queue figures in search replies
Every reply to a peer's search claimed a free upload slot, no speed and
an empty queue, whatever the sharer was doing, and searchers rank
sources on exactly those fields. A reply now says whether a slot is
free, the bytes per second of the last completed upload, and how many
uploads are waiting.
2026-09-07 15:16:20 +02:00
Michel de Graaf 049deee5a7 Merge pull request #64 from michel/lib/u32-message-codes
fix(lib): route on the full four-byte message code
2026-09-07 15:06:26 +02:00
Michel de Graaf f1aa94fecc fix(lib): route on the full four-byte message code
Dispatch read one byte of the code, so a code with a non-zero high byte
was handled as its low byte. Message::get_message_code now reads the
whole little-endian uint32 and handlers register by u32. PeerInit and
PierceFirewall, the only frames with a one-byte code, get their own
accessor for the listener's first frame.
2026-09-07 15:03:49 +02:00
Michel de Graaf 40a8eb1dd5 Merge pull request #63 from michel/lib/search-tokens
fix(lib): give each search its own token
2026-09-07 14:52:34 +02:00
Michel de Graaf 0458b24898 fix(lib): give each search its own token
A search token was the first five hex digits of the query's MD5, so two
queries could share one, and a peer's answer to either landed in whichever
search the map handed back first. Tokens now come from a counter, which
no two live searches can share.

The e2e test uses two queries that collide under the old scheme.
2026-09-07 14:52:25 +02:00
Michel de Graaf a99e848eda Merge pull request #62 from michel/lib/search-reply-cap
fix(lib): cap a search reply at 150 files
2026-09-07 14:46:32 +02:00
Michel de Graaf b9d3d7fcd5 fix(lib): cap a search reply at 150 files
A broad query against a big share was answered with every match, so one
search could hand the whole index to a stranger, and every such search
did it again. A reply now carries at most MAX_SEARCH_REPLY_FILES (150,
Nicotine+'s default), which is more sources than any searcher scrolls
through.
2026-09-07 14:44:16 +02:00
Michel de Graaf ef563fbb7e Merge pull request #60 from michel/feat/tui-pane-layout
feat(tui): results across the top, with panes you can hide, zoom and Tab through
2026-09-07 11:11:56 +02:00
Michel de Graaf d4e86187d2 refactor(tui): fold the keys overlay onto LogView and dedupe the pane lookups
The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.

One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
2026-09-07 10:51:41 +02:00
Michel de Graaf 478d976776 feat(tui): scroll long names in every list and page through popups and chat
The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
2026-09-07 08:16:20 +00:00
Michel de Graaf da8ab45303 feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.

Follows up on #55.
2026-09-07 07:49:32 +00:00
Michel de Graaf c91b97de7c Merge pull request #56 from michel/feat/tui-results-navigation
feat(tui): scroll long result names and page through results
2026-09-06 21:53:57 +02:00
Michel de Graaf 426639719c Merge branch 'develop' into feat/tui-results-navigation 2026-09-06 21:53:36 +02:00
Michel de Graaf 06bc48fcd9 perf(tui): bound the name scroll walk by the column width
Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on
develop, because every row segmented its whole filename into grapheme
clusters. Walk back from the end for at most one column of cells and
forward for at most the offset, so a row costs the column width rather
than the filename, and drop unicode-truncate for unicode-width. A tail
never starts on a combining mark.
2026-09-06 21:42:36 +02:00
Michel de Graaf 285221bbb1 Merge pull request #57 from michel/feat/cancel-download
feat: cancel a download from the TUI, the CLI, and the daemon
2026-09-06 21:40:58 +02:00
Michel de Graaf aa61a1a434 feat: cancel a download from the TUI, the CLI, and the daemon
Closes #54.

A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.

Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
2026-09-06 21:40:45 +02:00
Michel de Graaf 3193c73f5b feat(tui): scroll long result names and page through results
Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
2026-09-06 20:10:59 +02:00
Michel de Graaf 7bc696cab6 Merge pull request #52 from michel/daemon-reconnect
fix(daemon): reconnect after the server session drops
2026-09-02 14:45:50 +02:00
Michel de Graaf 67d979b809 fix(daemon): reconnect after the server session drops
A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:

- the daemon owned one session for its lifetime with no reconnect at all; and
- the lib's ServerActor queued a Login sent while disconnected instead of
  dialing, so even asking it to log in again never re-established a connection.

Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
2026-09-02 11:21:19 +02:00
Michel de Graaf 597f378f17 docs(website): drop soulfind server link from footer 2026-08-29 15:45:30 +02:00
Michel de Graaf d30d9fdf75 docs(website): move project links into LINKS 2026-08-29 15:45:30 +02:00
Michel de Graaf a9f4ab5e5b docs(website): list Player and Staccato, fix install pill overflow on mobile 2026-08-29 15:45:30 +02:00
Michel de Graaf 693926434d chore: release v14.1.2 2026-08-29 15:45:30 +02:00
Michel de Graaf e53b39cca7 Merge pull request #50 from michel/feat/room-member-stats
feat: per-member room statistics from the JoinRoom reply
2026-08-28 17:38:44 +02:00
Michel de Graaf 0ba00c98d7 Merge remote-tracking branch 'origin/develop' into feat/room-member-stats
# Conflicts:
#	soulseek-rs-lib/src/client/tests.rs
#	soulseek-rs-lib/tests/e2e.rs
#	soulseek-rs/src/daemon/openrpc.rs
#	soulseek-rs/src/daemon/proto/method.rs
#	soulseek-rs/src/daemon/server/mod.rs
#	soulseek-rs/src/remote/mod.rs
2026-08-28 15:18:37 +00:00
Michel de Graaf c704c8a59e Merge pull request #49 from michel/feat/watch-user
feat: watch and unwatch users (WatchUser 5 / UnwatchUser 6)
2026-08-28 17:04:40 +02:00
Michel de Graaf 62c99e4ab8 fix(cli): keep room users text output a bare roster
The e2e job caught this: adding status and file columns to the text record
broke `room_users_lists_who_is_in_the_room`, which asserts the line equals
the username. That test encodes a real contract -- the text output is a
roster to pipe into other commands -- and reformatting it was scope this
change did not need. The statistics stay on the record for `--json`.

Reproduced the failure locally against soulfind, then confirmed the same
test passing with the fix.
2026-08-28 14:55:02 +00:00
Michel de Graaf 9c1a6d317e test(e2e): cover watching against soulfind, and read the reply as written
The e2e job proved nothing about watching: it passed because the suite has
no test that exercises it. Two now run against a real soulfind.

- a watch reply carries status and share statistics in one message, checked
  against a subject sharing a known two files, and unwatching drops both the
  watch and the snapshot
- a username the server does not know is answered with an absence flag and
  must not stay in the watch list

Also reads the two obsolete fields of the reply as the two 32-bit values the
server writes, matching soulfind and the sibling GetUserStats (36) handler.
The byte count is identical, so this is not a behaviour change -- but the
handler now says what the wire actually carries.
2026-08-28 14:42:44 +00:00
Michel de Graaf 35a6fed3c3 fix: slotsfull is occupancy, not free slots, and cover it end to end
Running the e2e suite against a real soulfind showed the per-member slots
field was implemented backwards. The wire field is `slotsfull` -- a boolean
that is true when the member's upload slots are all occupied -- not a count
of free ones. `slots_free: u32` named it, documented it and printed it as
the opposite of what the server means, so a caller reading it would pick the
busiest peer to download from.

- types: `slots_full: bool`, with the wire name in the doc comment
- the DTO, the `room users` output and SKILL.md follow
- a new e2e test joins a room with a member sharing a known three files and
  asserts the counts, the roster alignment and the slot flag against
  soulfind, so the parsing is checked against a real server rather than only
  against frames this repo wrote itself
2026-08-28 14:39:34 +00:00
Michel de Graaf 60f7100e30 feat(daemon): publish room member statistics over RPC and the CLI
The statistics parsed from the JoinRoom reply were reachable only from the
library. They answer the question `room users` already exists to ask -- who
is in here, and are they worth downloading from -- so they belong on its
output rather than behind a second call.

- proto: room.member_stats, with a RoomUserStatsDto carrying status, share
  counts, free slots and country
- SessionApi::room_member_stats, so local and remote sessions stay
  interchangeable; the status word is decoded through one shared helper
  rather than a third copy of the match
- `room users` now emits status, average_speed, shared_files,
  shared_folders, slots_free and country, each null when the server said
  nothing, documented in SKILL.md
- docs/openrpc.json regenerated from the wire types
2026-08-28 13:27:09 +00:00
Michel de Graaf a91d35b672 feat(daemon): expose watching over the RPC surface and the CLI
The watch subscription was reachable only from the library. It is most
useful against a daemon -- that is the session that stays connected long
enough to receive the server's status pushes -- so it needs the wire.

- proto: user.watch / user.unwatch / user.watched, with a Watched response
- SessionApi gains watch_user / unwatch_user / watched_users, so a local and
  a remote session are still interchangeable above the seam
- CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md
  including the caveat that a one-shot run's watch ends with the run
- docs/openrpc.json regenerated from the wire types
2026-08-28 13:22:50 +00:00
Michel de Graaf b505718db9 feat(lib): watch and unwatch users (WatchUser 5 / UnwatchUser 6)
Adds the server-side watch subscription a buddy list needs: WatchUser (5)
asks the server to keep pushing a user's status, UnwatchUser (6) cancels it.
The reply to a watch carries the user's current status and share statistics,
which merge into the existing per-user snapshot read via Client::user_info.

Issue #47 asked for AddUserList (141) / WatchedUserAdded (142) /
WatchedUserRemoved (143). Those codes are EnableRoomInvitations,
ChangePassword and AddRoomOperator in the Soulseek protocol -- there are no
such messages on the wire. Watching is codes 5 and 6, so that is what this
implements.

- message/server/watch_user.rs: WatchUserHandler (code 5), tolerant of an
  absent user and of a truncated frame
- MessageFactory::build_watch_user / build_unwatch_user
- Client::watch_user / unwatch_user / watched_users
- The watch reply has no privileged flag, so it preserves whatever an
  earlier GetUserStatus recorded rather than asserting false
2026-08-28 12:44:43 +00:00
Michel de Graaf fa49ebd2eb feat(lib): per-member room statistics from the JoinRoom reply
The JoinRoom (code 14) reply carries, after the membership list, four
parallel vectors describing each member: status, share statistics, free
upload slots and country. The handler stopped after the usernames, so all of
it was discarded. It is now parsed and exposed as Client::room_member_stats.

Issue #48 asked for RoomStats (57) and RoomListStats (55). Those codes are
UserInterests and MyRecommendations in the Soulseek protocol; there is no
RoomStats message on the wire. Per-room user counts already arrive with
RoomList (64), which the client handles, and the per-member statistics are
part of the JoinRoom reply -- so that is where this adds them.

- join_room.rs: parse the stat vectors, each bounded by the payload so a
  hostile count cannot hang or overallocate
- types::RoomUserStats; every field looked up positionally so a server
  sending short vectors (or omitting countries) does not shift the roster
- ServerMessage/ClientOperation::RoomMemberStats and the context snapshot
  behind Client::room_member_stats, replaced on each rejoin
2026-08-28 12:44:37 +00:00
Michel de Graaf 0f91ffdcf3 feat(tui): dismiss a finished download with d
A completed row used to ignore d: only a queued transfer could be pulled
out of the list, so a finished one sat there until the next restart. d
now goes through one handler that drops a finished row whatever the
session thinks of it — a completed transfer can live only in this window,
restored from an earlier run, where there is nothing to forget.

The finished-status test was spelled out twice in clear_finished_downloads
and had to agree by hand; both callers now share is_finished().
2026-08-27 13:22:39 +02:00
Michel de Graaf eac340cdb8 docs: drop bot wording in favor of client 2026-08-18 08:39:04 +02:00
Michel de Graaf 7f5cff43ef Merge pull request #45 from michel/release-plz-2026-08-17T08-46-25Z
chore: release v14.1.1
2026-08-17 10:53:59 +02:00
Michel de Graaf 078ffee0e4 chore: release v14.1.1 2026-08-17 10:46:26 +02:00
Michel de Graaf 3000b4426e Merge branch 'develop': listener transfer-token and download token-race fixes 2026-08-17 10:44:09 +02:00