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
+76 -28
View File
@@ -1,10 +1,12 @@
use super::uploads::downloader_of;
use super::{
Arc, BROKER_CONNECT_TIMEOUT, Client, ClientContext, ClientOperation,
ConnectionType, Download, DownloadPeer, DownloadStatus, Peer, PeerMessage,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage, UploadJob,
build_search_response, debug, error, info, next_connect_token,
next_upload_token, sleep, thread, trace, warn,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage,
build_search_response, debug, error, info, next_connect_token, sleep,
thread, trace, warn,
};
use crate::message::server::MessageFactory;
impl Client {
pub(crate) fn listen_to_client_operations(
@@ -102,6 +104,14 @@ impl Client {
None,
);
}
// A peer that leaves must not keep occupying an
// upload slot: with a slot cap, a couple of
// departed peers would otherwise wedge uploads
// shut for the rest of the session.
Self::release_upload_slots(
&client_context,
&username,
);
}
ClientOperation::PierceFireWall(peer) => {
Self::pierce_firewall(
@@ -531,6 +541,12 @@ impl Client {
),
}
}
ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe()
{
ctx.wishlist_interval = Some(seconds);
}
}
ClientOperation::PeerConnected(username) => {
// An outbound control connection just handshook.
// Flush any downloads that were queued for this
@@ -655,16 +671,10 @@ impl Client {
requester_key,
filename,
} => {
// A peer queued one of our shared files. Look it
// up, mint an upload token, and offer it.
let downloader = requester_key
.strip_suffix(":direct")
.unwrap_or(&requester_key)
.to_string();
let token = next_upload_token();
let (registry, size) = match client_context
.write_safe()
{
// The peer served next may not be this one.
let downloader =
downloader_of(&requester_key).to_string();
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) =
ctx.shares.get(&filename)
@@ -677,16 +687,13 @@ impl Client {
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.uploads.insert(
token,
UploadJob {
downloader: downloader.clone(),
real_path,
virtual_path: filename.clone(),
size,
},
ctx.enqueue_upload(
&requester_key,
&downloader,
&filename,
real_path,
size,
);
(ctx.peer_registry.clone(), size)
}
Err(e) => {
error!(
@@ -695,18 +702,59 @@ impl Client {
);
continue;
}
};
}
Self::pump_upload_queue(&client_context);
}
ClientOperation::PlaceInQueueRequested {
requester_key,
filename,
} => {
let downloader = downloader_of(&requester_key);
let (registry, place) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.place_in_queue(
downloader, &filename,
),
),
Err(_) => continue,
};
// Silence would leave the peer guessing; a file
// no longer queued is being served, which is
// place 0 by the same convention Nicotine+ uses.
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&requester_key,
PeerMessage::ServeUpload {
token,
filename,
size,
},
PeerMessage::SendMessage(
MessageFactory::build_place_in_queue_response(
&filename,
place.unwrap_or(0),
),
),
);
}
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe()
{
debug!(
"[client] {} privileged users listed",
users.len()
);
ctx.set_privileged_users(users);
}
// Someone already waiting may have just been
// outranked, but a slot that is free should
// still be filled.
Self::pump_upload_queue(&client_context);
}
ClientOperation::OwnPrivileges(seconds) => {
if let Ok(mut ctx) = client_context.write_safe()
{
ctx.own_privileges = Some(seconds);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and