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:
@@ -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");
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user