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
+2
View File
@@ -1,6 +1,7 @@
mod file_search_response;
mod get_share_file_list;
mod peer_init;
mod place_in_queue_request;
mod place_in_queue_response;
mod queue_upload;
mod shared_file_list;
@@ -14,6 +15,7 @@ pub use file_search_response::{
};
pub use get_share_file_list::GetShareFileList;
pub use peer_init::PeerInit;
pub use place_in_queue_request::PlaceInQueueRequest;
pub use place_in_queue_response::PlaceInQueueResponse;
pub use queue_upload::QueueUploadHandler;
pub use shared_file_list::{
@@ -0,0 +1,45 @@
use crate::{
message::{Message, MessageHandler},
peer::PeerMessage,
};
use std::sync::mpsc::Sender;
/// `PlaceInQueueRequest` (peer code 51): a peer asking where the file they
/// queued sits in our upload queue.
///
/// Worth answering rather than dropping: a downloader that gets no position
/// cannot tell a long queue from a client that has forgotten about it.
pub struct PlaceInQueueRequest;
impl MessageHandler<PeerMessage> for PlaceInQueueRequest {
fn get_code(&self) -> u8 {
51
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
let filename = message.read_string();
let _ = sender.send(PeerMessage::IncomingPlaceInQueueRequest(filename));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_asked_about_filename_is_forwarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
message.write_string("@@share\\track.mp3");
message.set_pointer(8);
PlaceInQueueRequest.handle(&mut message, tx);
match rx.try_recv() {
Ok(PeerMessage::IncomingPlaceInQueueRequest(filename)) => {
assert_eq!(filename, "@@share\\track.mp3");
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -87,6 +87,38 @@ impl MessageFactory {
.clone()
}
/// Ask the server (code 92) how much of our own privilege time is left.
#[must_use]
pub fn build_check_privileges() -> Message {
Message::new().write_int32(92).clone()
}
/// Tell a peer where their queued file sits (peer code 44). Place counts
/// from 1.
#[must_use]
pub fn build_place_in_queue_response(
filename: &str,
place: u32,
) -> Message {
Message::new()
.write_int32(44)
.write_string(filename)
.write_int32(place)
.clone()
}
/// A wishlist search (server code 103). Same shape as a plain `FileSearch`,
/// but the server rate-limits it to the interval it announced in code 104
/// and does not count it against the normal search allowance.
#[must_use]
pub fn build_wishlist_search(token: u32, query: &str) -> Message {
Message::new()
.write_int32(103)
.write_int32(token)
.write_string(query)
.clone()
}
#[must_use]
pub fn build_set_status_message(status_code: u32) -> Message {
Message::new()
@@ -368,6 +400,37 @@ fn test_build_room_list_request() {
assert_eq!(vec![64, 0, 0, 0], message.get_data());
}
#[test]
fn test_build_check_privileges() {
assert_eq!(
vec![92, 0, 0, 0],
MessageFactory::build_check_privileges().get_data()
);
}
#[test]
fn test_build_place_in_queue_response() {
let message = MessageFactory::build_place_in_queue_response("song.mp3", 3);
let expect: Vec<u8> = [
44, 0, 0, 0, // code
8, 0, 0, 0, 115, 111, 110, 103, 46, 109, 112, 51, // "song.mp3"
3, 0, 0, 0, // place
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_wishlist_search() {
let message = MessageFactory::build_wishlist_search(12, "trance wax");
let expect: Vec<u8> = [
103, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_file_search_message() {
let message = MessageFactory::build_file_search_message(12, "trance wax");
+1 -1
View File
@@ -28,7 +28,7 @@ pub use message_factory::MessageFactory;
pub use message_user::MessageUser;
pub use parent_min_speed::ParentMinSpeedHandler;
pub use parent_speed_ratio::ParentSpeedRatioHandler;
pub use privileged_users::PrivilegedUsersHandler;
pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler};
pub use room_list::RoomListHandler;
pub use say_chatroom::SayChatroomHandler;
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
@@ -1,4 +1,3 @@
use crate::debug;
use std::sync::mpsc::Sender;
use crate::{
@@ -6,6 +5,12 @@ use crate::{
message::{Message, MessageHandler},
};
/// `PrivilegedUsers` (code 69): everyone the server counts as having donated,
/// sent once at login.
///
/// The names matter, not the count: they decide who overtakes whom in our
/// upload queue, which is the "recognition of privileges" an alternative client
/// is expected to implement.
pub struct PrivilegedUsersHandler;
impl MessageHandler<ServerMessage> for PrivilegedUsersHandler {
@@ -13,8 +18,105 @@ impl MessageHandler<ServerMessage> for PrivilegedUsersHandler {
69
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
let number = message.read_int32();
debug!("Number of privileged users: {}", number);
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let count = message.read_int32();
let mut users = Vec::new();
for _ in 0..count {
// Guard against a hostile count outrunning the payload.
if message.get_pointer() + 4 > message.get_size() {
break;
}
users.push(message.read_string());
}
let _ = sender.send(ServerMessage::PrivilegedUsers(users));
}
}
/// `CheckPrivileges` (code 92): how many seconds of our own privileges are
/// left. Zero means we have none.
pub struct CheckPrivilegesHandler;
impl MessageHandler<ServerMessage> for CheckPrivilegesHandler {
fn get_code(&self) -> u8 {
92
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let seconds = message.read_int32();
let _ = sender.send(ServerMessage::OwnPrivileges(seconds));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(build: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
build(&mut message);
message.set_pointer(8);
message
}
#[test]
fn the_user_list_is_kept_not_just_counted() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = payload(|m| {
m.write_int32(2)
.write_string("donor_a")
.write_string("donor_b");
});
PrivilegedUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::PrivilegedUsers(users)) => {
assert_eq!(users, ["donor_a", "donor_b"]);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn an_empty_list_is_still_an_answer() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = payload(|m| {
m.write_int32(0);
});
PrivilegedUsersHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::PrivilegedUsers(users)) => {
assert!(users.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_hostile_count_does_not_hang() {
// count=u32::MAX with no names: the guard must return promptly rather
// than loop four billion times.
let (tx, _rx) = std::sync::mpsc::channel();
let mut message = payload(|m| {
m.write_int32(u32::MAX);
});
PrivilegedUsersHandler.handle(&mut message, tx);
}
#[test]
fn our_own_remaining_privilege_time_is_reported() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = payload(|m| {
m.write_int32(86_400);
});
CheckPrivilegesHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::OwnPrivileges(seconds)) => {
assert_eq!(seconds, 86_400);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -1,4 +1,3 @@
use crate::debug;
use std::sync::mpsc::Sender;
use crate::{
@@ -6,17 +5,42 @@ use crate::{
message::{Message, MessageHandler},
};
/// `WishlistInterval` (code 104): how often the server will accept a wishlist
/// search from us. Usually 720 seconds, or 120 for a privileged account.
///
/// The value is the server's rate limit, not a suggestion, so it is kept rather
/// than logged: the re-search timer runs off it.
pub struct WishListIntervalHandler;
// The server tells us the wishlist search interval.
// This interval is almost always 12 minutes, or 2 minutes for privileged users.
impl MessageHandler<ServerMessage> for WishListIntervalHandler {
fn get_code(&self) -> u8 {
104
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
let number = message.read_int32();
debug!("Wishlist search interval: {} in seconds", number);
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let seconds = message.read_int32();
let _ = sender.send(ServerMessage::WishlistInterval(seconds));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_announced_interval_is_reported_not_discarded() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
message.write_int32(720);
message.set_pointer(8);
WishListIntervalHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::WishlistInterval(seconds)) => {
assert_eq!(seconds, 720);
}
other => panic!("unexpected: {other:?}"),
}
}
}