Commit Graph
477 Commits
Author SHA1 Message Date
Michel de Graaf 6d6f4f282e feat(distributed): learn our own upload speed by watching ourselves
The child limit is derived from the upload speed the server records for
us, and the server pushes a user's stats to whoever watches that user —
so a client that does not watch itself never learns its own figure and
would carry the default cap forever. Nicotine+ reads the same number off
the stats the server sends about the logged-in user.

We now watch ourselves as each session starts, and the stats that come
back — the initial reply and every later push — set the figure the limit
is derived from. Client::own_average_speed exposes it.
2026-09-09 17:11:04 +00:00
Michel de Graaf 773b5a4100 feat(interests): keep our own list and send it again each login
The server holds interests only for the session that set them, so a client
that does not re-send its list comes back from a reconnect with none. That
is why Nicotine+ sends its whole list at every login, and why it lowercases
each item before sending — the server matches them case-insensitively, so
two spellings would otherwise be held as two interests.

Ours now keeps the list, in one spelling, and puts it back on the wire when
a session starts; an interest set before there is a connection is kept
rather than dropped. A room's ticker board is also let go when we leave the
room, since the server sends the whole board again on the next join.
2026-09-09 17:06:08 +00:00
Michel de Graaf 0630b548b9 feat(protocol): folder requests, and heuristics matched to other clients
Reading Nicotine+ and slskd against what was just written turned up three
places where this client had the message right and the behaviour wrong.

Excluded search phrases (code 160) are not a filter on the searches we
send — the server already drops those. Nicotine+ applies them to the files
it offers, leaving out any whose path carries an excluded phrase, and a
reply with nothing left is not sent. Ours now does the same, and the
local refusal of outgoing searches is gone.

Serving children now follows the same rules as Nicotine+: the limit comes
from the server's ParentMinSpeed and ParentSpeedRatio against the upload
speed the server records for us (capped at ten, none at all below the
minimum), children are refused until something feeds us the search stream
and let go when it stops, a second link from a child we already carry is
refused, and AcceptChildren is re-sent whenever that answer changes. Where
the server has never reported our speed there is nothing to apply the
formula to, so the configured cap stands, which is how slskd runs.

Queued downloads now ask where they sit every five minutes, as Nicotine+
does, instead of only when a caller asks; a brokered file connection we
cannot complete reports CantConnectToPeer like a control connection does;
and a peer's shares can be asked for one folder at a time (peer code 36
and its reply, 37), which is what "download folder" is built on.
2026-09-09 17:01:19 +00:00
Michel de Graaf bf3e3ec4ad docs(readme): document the accept_children setting 2026-09-09 16:48:58 +00:00
Michel de Graaf 460031b532 feat(distributed): serve children in the search tree
The client read the distributed network but never carried it: a peer
dialling with a D connection was logged and dropped, and AcceptChildren
was hardcoded false, so this client took from the tree without ever
relaying a search for anyone else.

With accept_children set, peers hang from us: each is told where our
branch sits when it connects and again when the branch moves, every
search that reaches us is passed down, and a child that goes away loses
its slot. Ten at a time, matching Nicotine+, since each costs a socket
and a copy of the network's whole search stream — which is also why it is
opt-in, through the new accept_children config key (ignored without a
listener, since a child would have nothing to dial).
2026-09-09 16:47:55 +00:00
Michel de Graaf f5a97744f8 fix(messages): acknowledge stored private messages, and disconnect cleanly
Two bugs an e2e test found the moment one existed for this path.

A private message the server held while we were offline arrives with
new_message false, and the handler only acknowledged the ones flagged
new — so exactly the messages that need an ack never got one and the
server re-delivered them at every login, forever. Every delivery is now
acknowledged.

A dropped Client also left its server connection open, so the session
lived on: mail addressed to us went to a socket nobody was reading and a
later login collided with our own ghost. Client::disconnect closes the
server and peer connections, and Drop calls it.

Also covers two features that were never driven end to end: an offline
message delivered once and not again, and a wishlist search answered like
any other.
2026-09-09 16:39:10 +00:00
Michel de Graaf 19791d1699 feat(rooms): private room membership, operators and refusals
Private rooms were unreachable: the client could ask to join one but had
no way to invite, promote, resign or be told what happened, and a room
name already owned by someone else came back as silence rather than a
refusal (code 1003).

Adds the rosters the server pushes after login (133/148), the changes it
narrates to them (134/135, 143/144), our own standing (139/140, 145/146),
and the messages that drive them, with the rosters kept current in client
state. The daemon protocol and the TUI room transcript carry the new
events rather than dropping them.
2026-09-09 16:33:59 +00:00
Michel de Graaf 65c44cc66b feat(protocol): away status, excluded phrases, queue-place requests
Three features the client half-had. SetStatus (28) was only ever sent as
"online" at login, so there was no way to go away; excluded search phrases
(160) were parsed and logged into nowhere, so every client kept spending
searches on queries the server was never going to answer; and a queued
download could answer a peer asking about its place but could never ask
about its own.

Each is now driven end to end: the away status seen by another user, a
refused query on a stub server that announces phrases (soulfind keeps its
filters in a database the suite cannot seed), and a peer's answer landing
on the download as its queue position.
2026-09-09 16:27:20 +00:00
Michel de Graaf 60a0f8fc91 test(e2e): drive the new protocol messages against soulfind
Eleven scenarios over a real server: tickers reaching other members and
the board handed to a late joiner, the global room feed starting and
stopping, user and room searches answered by their targets, interests
driving similar users and recommendations, a password change taking
effect on the next login, one message reaching several users, and a
brokered dial we cannot complete reporting CantConnectToPeer to the peer
that is waiting on it.
2026-09-09 16:22:32 +00:00
Michel de Graaf 886f1b7092 feat(protocol): interests, tickers, global room and cant-connect
Adds the server messages the client had no way to speak: user and room
search (42/120), room tickers (113-116), the global room feed (150-152),
interests and the recommendation queries built on them (51/52/54/56/57/
110/111/112/117/118), a keepalive ping (32), gifted privileges (123), a
password change (142) and multi-user private messages (149).

CantConnectToPeer (1001) closes a real gap in both directions: a dial the
server asked us to make now answers the waiting peer instead of falling
back to brokering the same unreachable link the other way, and an incoming
one ends the wait on downloads queued for that peer rather than letting
them sit until a timeout.
2026-09-09 16:19:40 +00:00
Michel de Graaf 374a81bec1 feat(tui): resize panes by dragging pane borders
Press and drag the border under Results or the seam at a row pane's
right edge; the panes resize live and release ends the drag. Dragged
sizes are cell counts on PaneLayout, so W resets panes, zoom and sizes
in one key, and the reset-layout hint shows once anything is dragged.
Undragged panes keep their default Fill proportions.
2026-09-09 17:20:12 +02:00
Michel de Graaf 926c1c6697 feat(download): resume interrupted transfers 2026-09-09 15:49:35 +02:00
Michel de Graaf 7f84998c2e Merge pull request #93 from michel/nightly
fix(nightly): harden publishing and the live install command
2026-09-08 14:56:43 +02:00
Michel de Graaf a3105fb060 fix(docs): use deploy-independent nightly installer 2026-09-08 14:34:10 +02:00
Michel de Graaf 922ad6fc35 fix(ci): detect a missing nightly tag 2026-09-08 14:29:01 +02:00
Michel de Graaf 2975438949 fix(ci): retry nightly tag updates 2026-09-08 14:17:16 +02:00
Michel de Graaf 108c897fdf Merge pull request #92 from michel/nightly
feat(ci): publish installable nightly builds
2026-09-08 14:07:35 +02:00
Michel de Graaf 99205026f8 feat(ci): publish installable nightly builds 2026-09-08 13:40:50 +02:00
Michel de Graaf 0456ccbf59 Merge pull request #90 from michel/tui/rerun-search
feat(tui): run the highlighted search again with S
2026-09-08 13:33:52 +02:00
Michel de Graaf 3ef78c6de1 refactor(tests): share one soulfind fixture across the e2e suites
The TUI suite had grown its own copy of the soulfind spawner the protocol
suite already carried, and its own copy of the CLI suite's in-process
client and settle helpers. All four suites now spawn, log in, settle and
skip through `tests/common`: the CLI suite's own server goes too, and
the fixture checks its child is still alive before calling the port
answered, so a lost port cannot pass another server off as ours.
2026-09-08 13:12:12 +02:00
Michel de Graaf 76f4854169 feat(tui): run the highlighted search again with S
The search list survives a restart, but a restored query was only ever a
reminder: seeing what the network has now meant typing it out again. In
the Searches pane, `S` now re-runs the highlighted query in its place — no
duplicate row, no reordering — cancelling a run still collecting first and
starting the Results display over for what comes back.

The key is listed in the shortcut bar, the `?` overlay, the README and the
website. `start_search` is split into launching a search and showing it,
so both paths share one implementation.

A new `tui_e2e` suite drives the real window over a real session against
soulfind: keys in, a screen out, a sharing peer on the other end. It
restores a query from a previous run's state directory, presses `S`, and
checks the peer's file lands on screen — then again once the query has
been on the wire, proving a repeat is answered and the display does not
stack. To make that possible `MainTui` is exported with `handle_key_event`,
`render`, `poll_session` and a `state()` accessor, and CI runs the suite
with `SOULSEEK_E2E_REQUIRED=1`.

Closes #85
2026-09-08 13:11:40 +02:00
Michel de Graaf af1f9241e4 Merge pull request #91 from michel/tui/results-folder-column
feat(tui): folder column, paged long lists, and filters in every popup
2026-09-08 12:32:08 +02:00
Michel de Graaf 0cacc8cc10 refactor(tui): one page window and one filter editor for every list
Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.

Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
2026-09-08 11:24:59 +02:00
Michel de Graaf 607532cc44 feat(tui): search a room's log and members, and a conversation
A busy room's log scrolls past and its member list runs to a thousand
names, with nothing to narrow either. In a room / now filters the log
to lines mentioning the text and u filters the member list, the same
way the room list and the results filter: type, Enter keeps it, Esc
clears it before it leaves the room. The inbox takes / over the open
conversation. The member keys, b and m among them, act on the narrowed
list, and the titles say what is filtered and how many made it.
2026-09-08 11:24:53 +02:00
Michel de Graaf 6c4e60bb0e perf(tui): wrap a chat log as it arrives instead of on every frame
A room's chat log was wrapped to the pane width from the first line on
every frame, so a busy room lagged more the longer it stayed open: in
the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now
live with the log and a draw only wraps the lines that arrived since
the last one, starting over when the width changes. The same cache
serves the private-message box, and the room list draws only its
visible page like the other tables. The 30,000-line room now draws in
0.4ms.
2026-09-08 11:24:46 +02:00
Michel de Graaf b8da8668b4 feat(tui): folder jumps, open-all and a path filter in the browse popup
A browsed share only moved a row at a time, so a folder of a few
hundred files stood between the user and the next folder. J and K now
jump between folder rows, H and L close or open every folder, and /
filters the whole tree by path the way the results and the room list
filter: type to narrow, Enter keeps it, Esc clears it, then hides. A
folder under a filter shows the paths that match and d downloads just
those. The keys popup gains a browse section and the bar lists them.
2026-09-08 11:24:41 +02:00
Michel de Graaf 0931bb01b4 perf(tui): flatten a browsed share once and draw one page of it
Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.

The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.

The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
2026-09-08 11:24:36 +02:00
Michel de Graaf 1fcde0a78b feat(tui): W puts the pane layout back
Hiding panes with w and zooming with z had no way back short of
pressing each hidden pane's number and Esc. W shows every pane and
leaves zoom in one go, and the shortcut bar offers it only while there
is a layout to reset.
2026-09-08 11:24:31 +02:00
Michel de Graaf 995fe98c01 feat(tui): show the clear-all key in the searches pane's shortcuts
C has cleared every search for a while, and the keys popup says so, but
the pane's own shortcut bar never mentioned it, so it read as if d on
each search were the only way. The bar now lists it next to d → remove.
2026-09-08 11:11:45 +02:00
Michel de Graaf 320d0a81a6 perf(tui): build only the visible page of result rows
Scrolling a long results list was laggy: every frame built a table row
for every result, and ratatui then drew the forty-odd that fit. At the
10,000-file cap that cost 25-32ms a frame in release (69ms in debug), in
attached and standalone mode alike, so a held key stuttered.

The pane now slices to the page ratatui would show, by its own rule:
stay where the last frame left off, pulled along only as far as keeps
the selection in view. The table gets a page-local state and the page
start is written back into the real one. The page is counted with the
same page_of that PgUp/PgDn use, so paging and drawing agree, and a
selection past the end lands on the last row as ratatui put it. Idle
frames drop to 1.5ms and a keypress to 0.6ms at the same size, measured
live in both modes.

ratatui has no lazy rows of its own: the lazy_rows proposal was not
merged and moved to the experimental ratatui-table crate, so windowing
in the caller is the recommended approach today.
2026-09-08 11:11:39 +02:00
Michel de Graaf b0abb90f4f Enhance README with installation instructions and links
Added quick install instructions and updated project description with a link to the website.
2026-09-08 11:02:41 +02:00
Michel de Graaf 8a9a74460b docs: redraw the demo's results rows with the folder column
The README's recording still showed the whole path in one column. Every
results line in it, the rows a popup half covers and the ones the
recording caught mid-paint included, is redrawn from what the pane now
renders at that width, with the same names, sizes and scroll position.
2026-09-08 07:01:19 +00:00
Michel de Graaf 95cadc49d2 docs(website): describe the results pane's folder column
The pane guide and key table on the docs page, and the note above the
hero terminal, still spoke of one column for the whole path.
2026-09-08 06:40:17 +00:00
Michel de Graaf 2733ee26f7 refactor(tui): trim the folder column change to what it needs
The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
2026-09-08 05:59:39 +00:00
Michel de Graaf da371d4176 feat(tui): show a result's file name and folder in columns of their own
The results list held the whole shared path in one column, so a search
was read through its folder: every row began with the same peer prefix
and the file name, the part being searched for, sat off the right edge.
Split the column at the last separator, the file name first with the
larger share of the width and the folder dimmed beside it, so names line
up and read at a glance.

The one scroll offset moves both columns together, and $ runs to the end
of whichever overflows further. The website's mock of the pane follows.

Suggested in #55.
2026-09-08 05:59:38 +00:00
Michel de Graaf 1a1d4d529d Merge pull request #84 from michel/lib/distributed-leaf
feat(lib): join the distributed search network as a leaf
2026-09-08 02:06:39 +02:00
Michel de Graaf 7157ba247c Merge pull request #83 from michel/lib/api-hygiene
refactor(lib): keep the actors inside the crate and mark the growing enums
2026-09-08 02:06:33 +02:00
Michel de Graaf 93bfa053e9 Merge pull request #82 from michel/lib/listing-attributes
feat(lib): carry audio attributes in browse and folder listings
2026-09-08 02:06:27 +02:00
Michel de Graaf 2c15e0600e Merge pull request #81 from michel/lib/audio-attributes
feat(lib): read audio attributes from shared files at scan time
2026-09-08 02:06:22 +02:00
Michel de Graaf 85f26134ed fix(lib): free a silent parent's slot and pace the moves we report
A candidate that accepted the dial and then said nothing kept its slot
for the session, and ten of those blocked every later batch; a link now
closes after a minute of silence. A parent that flaps its level made us
tell the server four messages per flap; moves are now told at most once
a second, the latest one winning. A parent that reports level zero is
its own root from then on, whatever it said before. The wait for a
searcher's control connection runs from the last message queued for it,
not the first. A successful login is pinned to reset the leaf.
2026-09-08 01:45:07 +02:00
Michel de Graaf 9d6a5aaa98 fix(lib): harden the distributed leaf against hostile and slow parents
Review of the leaf found the seams a stranger on the other end of a D
connection could pull at, and a few that a slow one would.

- A code-93 envelope is unwrapped once, as Nicotine+ does; a frame made
  of nothing but envelopes used to recurse once per byte.
- A branch level is validated before it is used: negative levels leave
  the candidate unadoptable and the largest one no longer overflows.
- The link reads through the crate's MessageReader, so a frame split
  across a read timeout is no longer torn or mistaken for a closed link.
- Every event a link raises carries its link number, so a stale thread
  cannot adopt, close or move a newer link to the same user.
- Later branch facts from the parent move our own place and are told to
  the server; a fresh login resets the leaf, whose stance is now the one
  place the four announcement messages come from.
- Candidates from a later batch join the ones in flight instead of
  cancelling them, capped at ten; searches from the parent are budgeted
  at fifty a second; a reply queued for a searcher the server cannot
  place expires after a minute.
- Truncated frames and half-read PossibleParents entries are dropped
  instead of yielding an empty root, a level of zero or a phantom
  candidate.

The e2e now has a searcher of its own, so the answer must go to whoever
asked and not to the parent that relayed it.
2026-09-08 01:45:07 +02:00
Michel de Graaf 7d7b8fe98c feat(lib): join the distributed search network as a leaf
The server hands every client a list of possible parents and expects it to
hang from one so searches also travel peer to peer. Until now we told the
server we were a lone root and left it at that, which the network tolerates
but does not favour.

A leaf now dials each PossibleParents candidate over a D connection, adopts
the first that passes a search down after stating its branch, announces the
new branch root and level to the server, and answers those searches the same
way as ones from the server. Losing the parent or a ResetDistributed puts it
back to a parentless stance. The leaf takes no children.

The Leaf state machine is pure so its rules are unit-tested without sockets;
the e2e test drives a real client from a mock parent, since soulfind never
hands out parents.
2026-09-08 01:45:07 +02:00
Michel de Graaf 1976b49792 feat(lib): decode distributed frames and announce a parentless stance
The distributed search network carries searches from parent to child
over connections whose frames have a one-byte code; the library had no
reader for them. This adds the codec for Search, BranchLevel,
BranchRoot and the embedded envelope, with the identifier check that
keeps malformed frames from being answered, and completes the post-login
stance Nicotine+ sends: HaveNoParent, BranchRoot (ourselves),
BranchLevel 0 and AcceptChildren false, in place of HaveNoParent alone.
2026-09-08 01:45:07 +02:00
Michel de Graaf 976dd706d7 refactor(lib): export the ConnectionType parse error and document what hosts can still match 2026-09-08 01:45:02 +02:00
Michel de Graaf 07dd163156 refactor(lib): keep the actors inside the crate and mark the growing enums
Every release since 14 has been a major, because the actor mailboxes,
the share index's struct and the CLI's command enum were all exhaustive
public API: adding a peer message, a scanned field or a subcommand read
as a breaking change to the library. The actors, dispatcher, peer
plumbing and download store are how the client is built, not what a host
programs against, so they move inside the crate; PeerMessage, SharedFile
and the CLI command enums are non-exhaustive; ConnectionType is
re-exported at the root for the one thing hosts used it for. A one-time
break so that the next additions are not.
2026-09-08 01:45:02 +02:00
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00
Michel de Graaf 5a20d69056 fix(lib): count the ID3 tag once and honour CRC frames when probing an MP3
The tag's length was subtracted twice from the audio length when the
tag fit in the first read, and not at all when it did not, so durations
came out short or long by the tag; a frame with the protection bit
clear carries a two-byte CRC before its side info, which put the Xing
block two bytes off; and a Xing block without a frame count threw the
whole file's attributes away instead of falling back to the frame
header. Each has a test now.
2026-09-08 01:44:19 +02:00
Michel de Graaf a58aedf479 feat(lib): read audio attributes from shared files at scan time
Every search reply we sent carried no bitrate or duration, so our files
failed every bitrate filter in other clients and sorted last in
SoulseekQt, whatever their quality. The share scan now reads each MP3's
first frame header (and Xing block for variable bitrate) and each FLAC's
STREAMINFO, advertising bitrate, duration, VBR, sample rate and bit
depth as far as the format gives them; other files stay bare. Only the
file's head is read, no crate is needed, and a file that does not parse
is shared without attributes rather than skipped.
2026-09-08 01:44:19 +02:00
Michel de Graaf d28e33802f Merge pull request #86 from michel/ci/stress-serve-browses-while-offering
fix(lib): keep a replaced peer connection open for the reply in flight
2026-09-08 01:43:42 +02:00
Michel de Graaf bcf71dabec fix(lib): parse what a peer sent before acting on its close
Draining several buffers a tick meant a reply and the close after it
could land in one call, and the close was acted on first: the reply sat
unparsed in the reader while the actor disconnected. Only the first read
of a drain reports an error now; the close is seen again on the next
tick, after the reply has been handled.
2026-09-08 01:23:19 +02:00