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`.
This commit is contained in:
Michel de Graaf
2026-07-26 14:20:07 +02:00
committed by GitHub
parent eda762d067
commit 9b87bd6a0f
29 changed files with 2991 additions and 145 deletions
+21 -3
View File
@@ -2,9 +2,10 @@ use crate::actor::{Actor, ActorHandle, ConnectionState};
use crate::client::ClientOperation;
use crate::dispatcher::MessageDispatcher;
use crate::message::peer::{
FileSearchResponse, GetShareFileList, PeerInit, PlaceInQueueResponse,
QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler,
TransferRequest, TransferResponse, UploadFailedHandler,
FileSearchResponse, GetShareFileList, PeerInit, PlaceInQueueRequest,
PlaceInQueueResponse, QueueUploadHandler, SharedDirectory,
SharedFileListResponseHandler, TransferRequest, TransferResponse,
UploadFailedHandler,
};
use crate::message::server::MessageFactory;
use crate::message::{Handlers, Message, MessageReader, MessageType};
@@ -39,6 +40,8 @@ pub enum PeerMessage {
RequestTransfer(Download),
/// A peer queued one of our shared files for download (they sent us code 43).
IncomingQueueUpload(String),
/// A peer asked where their queued file sits (they sent us code 51).
IncomingPlaceInQueueRequest(String),
/// A peer asked to browse our shared files (they sent us code 4).
ShareListRequested,
/// A peer we are browsing sent us their shared-file listing (code 5).
@@ -164,6 +167,7 @@ impl PeerActor {
handlers.register_handler(TransferResponse);
handlers.register_handler(GetShareFileList);
handlers.register_handler(UploadFailedHandler);
handlers.register_handler(PlaceInQueueRequest);
handlers.register_handler(PlaceInQueueResponse);
handlers.register_handler(QueueUploadHandler);
handlers.register_handler(SharedFileListResponseHandler);
@@ -235,6 +239,20 @@ impl PeerActor {
PeerMessage::IncomingQueueUpload(filename) => {
self.handle_incoming_queue_upload(filename);
}
PeerMessage::IncomingPlaceInQueueRequest(filename) => {
let requester_key = self.peer_username();
if let Err(e) = self.client_channel.send(
ClientOperation::PlaceInQueueRequested {
requester_key,
filename,
},
) {
error!(
"[peer_actor] forward IncomingPlaceInQueueRequest: {}",
e
);
}
}
PeerMessage::ServeUpload {
token,
filename,
+49 -1
View File
@@ -1,6 +1,7 @@
use crate::actor::{Actor, ActorHandle, ConnectionState};
use crate::client::ClientOperation;
use crate::dispatcher::MessageDispatcher;
use crate::message::server::CheckPrivilegesHandler;
use crate::message::server::ConnectToPeerHandler;
use crate::message::server::ExcludedSearchPhrasesHandler;
use crate::message::server::FileSearchHandler;
@@ -166,6 +167,20 @@ pub enum ServerMessage {
token: u32,
query: String,
},
/// A wishlist search (code 103): a stored query the server lets us repeat
/// once per announced interval.
WishlistSearch {
token: u32,
query: String,
},
/// The server announced how often it will accept a wishlist search.
WishlistInterval(u32),
/// Everyone the server counts as privileged (code 69).
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left (code 92).
OwnPrivileges(u32),
/// Ask the server for the answer to the above.
CheckPrivileges,
/// A search the server distributed to us from another user; if it matches
/// our shares we reply with a FileSearchResponse.
FileSearchRequest {
@@ -391,7 +406,7 @@ impl ServerActor {
handlers.register_handler(WishListIntervalHandler);
handlers.register_handler(ParentMinSpeedHandler);
handlers.register_handler(ParentSpeedRatioHandler);
handlers.register_handler(PrivilegedUsersHandler);
handlers.register_handler(CheckPrivilegesHandler);
handlers.register_handler(FileSearchHandler);
handlers.register_handler(GetPeerAddressHandler);
handlers.register_handler(ConnectToPeerHandler);
@@ -549,6 +564,39 @@ impl ServerActor {
} => {
self.handle_file_search_request(username, token, query);
}
other => self.handle_standing_message(other),
}
}
/// The wishlist and privilege traffic: standing searches (codes 103/104) and
/// who is privileged (codes 69/92).
///
/// Split out only because it keeps `handle_message`'s match to a readable
/// length; there is no behaviour here beyond dispatch.
fn handle_standing_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::WishlistSearch { token, query } => {
self.queue_message(MessageFactory::build_wishlist_search(
token, &query,
));
}
ServerMessage::WishlistInterval(seconds) => {
self.forward_to_client(ClientOperation::WishlistInterval(
seconds,
));
}
ServerMessage::PrivilegedUsers(users) => {
self.forward_to_client(ClientOperation::PrivilegedUsers(users));
}
ServerMessage::OwnPrivileges(seconds) => {
self.forward_to_client(ClientOperation::OwnPrivileges(seconds));
}
ServerMessage::CheckPrivileges => {
self.queue_message(MessageFactory::build_check_privileges());
}
other => {
error!("[server] unroutable message: {:?}", other);
}
}
}