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
+6 -2
View File
@@ -11,6 +11,7 @@ pub mod settings;
pub mod skills;
pub mod social;
pub mod transfer;
pub mod wish;
use crate::cli::{Commands, MessageCommand, RoomCommand};
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
@@ -191,13 +192,16 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
Commands::Message(MessageCommand::Read { duration, follow }) => {
social::message_read(ctx, listen_span(duration, follow))
}
Commands::Serve(args) => peer::serve(ctx, &args),
Commands::User(args) => peer::user(ctx, &args),
Commands::Whoami => peer::whoami(ctx),
// These need no account, so main.rs answers them before asking for
// credentials; the arms exist only to keep the match exhaustive.
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
Commands::Config(_)
// These need the config file as well as a session, so main.rs runs
// them where the store is in scope.
Commands::Serve(_)
| Commands::Wish(_)
| Commands::Config(_)
| Commands::Shares(_)
| Commands::Skills(_)
| Commands::Completions(_) => {
+41 -1
View File
@@ -12,6 +12,11 @@ use std::time::{Duration, Instant};
/// How often the serve loop samples the upload table.
const POLL: Duration = Duration::from_millis(500);
/// How long `whoami` waits for the server's privilege answer. It is one small
/// message on an already-open connection, so a server that has not replied by
/// now is not going to.
const PRIVILEGE_TIMEOUT: Duration = Duration::from_secs(5);
pub fn whoami(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
let (folders, files) = session.client.shared_counts();
@@ -23,10 +28,29 @@ pub fn whoami(ctx: &Ctx) -> CliResult {
shared_folders: folders,
shared_files: files,
download_dir: ctx.download_dir.clone(),
privilege_seconds: own_privileges(&session.client),
});
Ok(())
}
/// Ask the server how much privilege time this account has left.
///
/// A server that does not answer leaves the field null rather than reporting a
/// zero, because "no privileges" and "did not say" are different facts.
fn own_privileges(client: &soulseek_rs::Client) -> Option<u32> {
if client.check_privileges().is_err() {
return None;
}
let deadline = Instant::now() + PRIVILEGE_TIMEOUT;
while Instant::now() < deadline {
if let Some(seconds) = client.own_privilege_seconds() {
return Some(seconds);
}
std::thread::sleep(Duration::from_millis(50));
}
None
}
pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
let session = Session::open(ctx)?;
session.client.request_user_info(&args.user).map_err(|e| {
@@ -69,7 +93,13 @@ pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
Ok(())
}
pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
/// Stay online sharing files, and — with `--wishlist` — re-run the standing
/// searches on the interval the server sets while doing so.
pub fn serve(
ctx: &Ctx,
args: &ServeArgs,
mut sweeper: Option<crate::commands::wish::Sweeper>,
) -> CliResult {
if ctx.settings.shared_directories.is_empty() {
return Err(CliError::usage(
"nothing to share: pass --shared-dir, or add one with `shares add`",
@@ -82,6 +112,10 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
}
let session = Session::open(ctx)?;
if let Some(slots) = args.upload_slots {
session.client.set_upload_slots(usize::from(slots));
ctx.out.status(&format!("{slots} upload slots"));
}
let (folders, files) = session.client.shared_counts();
ctx.out.status(&format!(
"serving {files} files in {folders} folders on port {}",
@@ -109,6 +143,9 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
reported.insert(key, record);
}
}
if let Some(sweeper) = sweeper.as_mut() {
sweeper.tick(ctx, &session.client);
}
std::thread::sleep(POLL);
}
Ok(())
@@ -117,6 +154,9 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
/// Flatten a live upload into the record `serve` reports.
fn upload_record(upload: &UploadInfo) -> UploadRecord {
let (status, reason) = match &upload.status {
UploadStatus::Queued(place) => {
("queued", Some(format!("place {place}")))
}
UploadStatus::InProgress => ("uploading", None),
UploadStatus::Completed => ("completed", None),
UploadStatus::Cancelled => ("cancelled", None),
+1 -1
View File
@@ -19,7 +19,7 @@ pub struct Store {
}
impl Store {
fn save(&self, config: &FileConfig) -> CliResult {
pub fn save(&self, config: &FileConfig) -> CliResult {
let Some(path) = &self.path else {
return Err(CliError::usage(
"no config file to write to (running with --no-config)",
+222 -29
View File
@@ -1,7 +1,9 @@
//! Finding files and fetching them: `search`, `download`, and `get`.
use super::{Ctx, Session, social};
use crate::cli::{DownloadArgs, GetArgs, Pick, SearchArgs, SortKey};
use crate::cli::{
DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey,
};
use crate::output::{
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
};
@@ -19,25 +21,69 @@ use std::time::{Duration, Instant};
const ATTR_BITRATE: u32 = 0;
const ATTR_DURATION: u32 = 1;
/// What to keep out of the raw result set.
#[derive(Debug, Clone, Copy, Default)]
/// What to keep out of the raw result set. Every field is a further
/// restriction: a candidate has to satisfy all of them.
///
/// `exclude` and `extensions` are stored already lowercased and without leading
/// dots — see [`Filter::from`] — so matching a result costs no allocation. A
/// search returns thousands of files and every one of them is tested.
#[derive(Debug, Clone, Default)]
pub struct Filter {
pub min_bitrate: Option<u32>,
pub free_slots_only: bool,
/// Terms that must not appear anywhere in the remote path, lowercased.
pub exclude: Vec<String>,
/// File extensions to keep, lowercased and dotless.
pub extensions: Vec<String>,
pub min_size: Option<u64>,
pub max_size: Option<u64>,
}
impl Filter {
const fn keeps(&self, candidate: &SearchRecord) -> bool {
fn keeps(&self, candidate: &SearchRecord) -> bool {
if self.free_slots_only && !candidate.free_slot {
return false;
}
match (self.min_bitrate, candidate.bitrate) {
(Some(minimum), Some(bitrate)) => bitrate >= minimum,
// An unknown bitrate cannot satisfy a bitrate floor.
(Some(_), None) => false,
(None, _) => true,
if self.min_size.is_some_and(|min| candidate.size < min) {
return false;
}
if self.max_size.is_some_and(|max| candidate.size > max) {
return false;
}
// An unknown bitrate cannot satisfy a bitrate floor.
if self
.min_bitrate
.is_some_and(|min| candidate.bitrate.is_none_or(|have| have < min))
{
return false;
}
if self.exclude.is_empty() && self.extensions.is_empty() {
return true;
}
let path = candidate.path.to_lowercase();
!self.exclude.iter().any(|term| path.contains(term.as_str()))
&& (self.extensions.is_empty() || self.matches_extension(&path))
}
fn matches_extension(&self, lowercase_path: &str) -> bool {
let name = lowercase_path
.rsplit(['/', '\\'])
.next()
.unwrap_or_default();
let Some((_, actual)) = name.rsplit_once('.') else {
return false;
};
self.extensions.iter().any(|wanted| wanted == actual)
}
}
/// Lowercase and strip a leading dot, so the comparison in [`Filter::keeps`] is
/// a plain equality however the flag was spelled.
fn normalized(terms: &[String]) -> Vec<String> {
terms
.iter()
.map(|term| term.trim_start_matches('.').to_lowercase())
.collect()
}
/// Flatten per-peer results into individual files, dropping what `filter`
@@ -112,6 +158,19 @@ pub struct Request {
pub size: u64,
}
impl From<&FilterArgs> for Filter {
fn from(args: &FilterArgs) -> Self {
Self {
min_bitrate: args.min_bitrate,
free_slots_only: args.free_slots,
exclude: normalized(&args.exclude),
extensions: normalized(&args.extension),
min_size: args.min_size,
max_size: args.max_size,
}
}
}
impl From<&SearchRecord> for Request {
fn from(candidate: &SearchRecord) -> Self {
Self {
@@ -123,7 +182,7 @@ impl From<&SearchRecord> for Request {
}
/// Filter, rank, and cap a raw result set, failing when nothing survives.
fn ranked(
pub(super) fn ranked(
results: &[SearchResult],
filter: &Filter,
sort: SortKey,
@@ -146,10 +205,7 @@ pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult {
let results = collect(ctx, &session.client, &args.query, false)?;
let found = ranked(
&results,
&Filter {
min_bitrate: args.min_bitrate,
free_slots_only: args.free_slots,
},
&Filter::from(&args.filter),
args.sort,
args.limit,
&args.query,
@@ -206,10 +262,7 @@ pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult {
};
let found = ranked(
&results,
&Filter {
min_bitrate: args.min_bitrate,
free_slots_only: args.free_slots,
},
&Filter::from(&args.filter),
SortKey::Best,
limit,
&args.query,
@@ -634,6 +687,10 @@ mod tests {
}
}
fn paths_of(found: &[SearchRecord]) -> Vec<String> {
found.iter().map(|c| c.path.clone()).collect()
}
fn sample() -> Vec<SearchResult> {
vec![
result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]),
@@ -667,28 +724,164 @@ mod tests {
assert_eq!(best.speed, 900);
}
/// Build a filter the way a command does, so the tests cover the flag
/// normalisation (case, leading dots) rather than assuming it.
fn filter(build: impl FnOnce(&mut FilterArgs)) -> Filter {
let mut args = FilterArgs::default();
build(&mut args);
Filter::from(&args)
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_string()).collect()
}
#[test]
fn min_bitrate_drops_lower_and_unknown_bitrates() {
let filter = Filter {
min_bitrate: Some(256),
free_slots_only: false,
};
let found = candidates(&sample(), &filter);
let paths: Vec<&str> = found.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, ["b\\best.mp3", "c\\good.mp3"]);
let filter = filter(|a| a.min_bitrate = Some(256));
assert_eq!(
paths_of(&candidates(&sample(), &filter)),
["b\\best.mp3", "c\\good.mp3"]
);
}
#[test]
fn free_slots_only_keeps_peers_advertising_a_slot() {
let filter = Filter {
min_bitrate: None,
free_slots_only: true,
};
let filter = filter(|a| a.free_slots = true);
let found = candidates(&sample(), &filter);
assert!(found.iter().all(|c| c.free_slot));
assert!(found.iter().all(|c| c.user != "busy"));
}
#[test]
fn exclude_drops_a_path_containing_the_term_whatever_its_case() {
let filter = filter(|a| a.exclude = strings(&["LOW"]));
let found = candidates(&sample(), &filter);
assert!(found.iter().all(|c| c.path != "a\\low.mp3"));
assert_eq!(found.len(), 3);
}
#[test]
fn exclude_matches_any_term_not_all_of_them() {
let filter = filter(|a| a.exclude = strings(&["low", "unknown"]));
assert_eq!(
paths_of(&candidates(&sample(), &filter)),
["b\\best.mp3", "c\\good.mp3"]
);
}
#[test]
fn extension_keeps_only_that_type_however_it_is_written() {
let mixed = vec![result(
"peer",
1,
1,
vec![
file("a.mp3", 1, None),
file("b.FLAC", 2, None),
file("c.ogg", 3, None),
],
)];
for spelling in [".flac", "flac", "FLAC", ".FLAC"] {
let filter = filter(|a| a.extension = strings(&[spelling]));
assert_eq!(
paths_of(&candidates(&mixed, &filter)),
["b.FLAC"],
"--extension {spelling} should match b.FLAC"
);
}
}
#[test]
fn several_extensions_are_a_union() {
let mixed = vec![result(
"peer",
1,
1,
vec![
file("a.mp3", 1, None),
file("b.flac", 2, None),
file("c.ogg", 3, None),
],
)];
let filter = filter(|a| a.extension = strings(&["mp3", "ogg"]));
assert_eq!(paths_of(&candidates(&mixed, &filter)), ["a.mp3", "c.ogg"]);
}
#[test]
fn an_extensionless_file_never_satisfies_an_extension_filter() {
let odd = vec![result("peer", 1, 1, vec![file("README", 1, None)])];
let filter = filter(|a| a.extension = strings(&["mp3"]));
assert!(candidates(&odd, &filter).is_empty());
}
#[test]
fn a_dot_in_a_folder_name_is_not_the_files_extension() {
// Only the last path segment decides the extension, or every file under
// "Album (2004).v0" would look like a .v0 file.
let nested = vec![result(
"peer",
1,
1,
vec![file("Album (2004).v0\\track.mp3", 1, None)],
)];
assert!(
candidates(&nested, &filter(|a| a.extension = strings(&["v0"])))
.is_empty(),
"the folder's .v0 suffix must not count as the file's extension"
);
assert_eq!(
paths_of(&candidates(
&nested,
&filter(|a| a.extension = strings(&["mp3"]))
)),
["Album (2004).v0\\track.mp3"]
);
}
#[test]
fn size_bounds_are_inclusive_on_both_ends() {
let filter = filter(|a| {
a.min_size = Some(50);
a.max_size = Some(70);
});
assert_eq!(
paths_of(&candidates(&sample(), &filter)),
["c\\good.mp3", "c\\unknown.mp3"]
);
}
#[test]
fn each_size_bound_works_without_the_other() {
let floor = filter(|a| a.min_size = Some(70));
assert_eq!(
paths_of(&candidates(&sample(), &floor)),
["b\\best.mp3", "c\\unknown.mp3"]
);
let ceiling = filter(|a| a.max_size = Some(10));
assert_eq!(paths_of(&candidates(&sample(), &ceiling)), ["a\\low.mp3"]);
}
#[test]
fn filters_combine_as_an_and() {
let filter = filter(|a| {
a.free_slots = true;
a.min_bitrate = Some(200);
a.min_size = Some(20);
a.exclude = strings(&["bad"]);
a.extension = strings(&["mp3"]);
});
assert_eq!(paths_of(&candidates(&sample(), &filter)), ["c\\good.mp3"]);
}
#[test]
fn a_filter_that_rejects_everything_is_the_no_results_verdict() {
let filter = filter(|a| a.min_size = Some(u64::MAX));
let error = ranked(&sample(), &filter, SortKey::Best, 0, "q")
.expect_err("nothing survives the filter");
assert_eq!(error.exit, Exit::NoResults);
}
#[test]
fn best_prefers_a_free_slot_over_a_higher_bitrate() {
let mut found = candidates(&sample(), &Filter::default());
+257
View File
@@ -0,0 +1,257 @@
//! Standing searches: `wish`.
//!
//! A wishlist is what makes a client keep looking for something it did not find
//! today. The server treats it as a distinct message (code 103) and rate-limits
//! it to the interval it announces at login, so the schedule is the server's to
//! set — we only decide which queries to send and what to print.
use super::{Ctx, Session, settings::Store, transfer};
use crate::cli::{SortKey, WishRunArgs};
use crate::output::{CliError, CliResult, Out, WishRecord};
use crate::persist::config::FileConfig;
use soulseek_rs::Client;
use std::time::Instant;
pub fn add(out: &Out, store: &Store, query: &str) -> CliResult {
let mut updated = store.config.clone();
if !updated.add_wish(query) {
if query.trim().is_empty() {
return Err(CliError::usage("a wish needs a query"));
}
out.status(&format!("already wishing for '{}'", query.trim()));
return Ok(());
}
store.save(&updated)?;
out.emit(&WishRecord {
query: query.trim().to_string(),
});
Ok(())
}
pub fn remove(out: &Out, store: &Store, query: &str) -> CliResult {
let mut updated = store.config.clone();
if !updated.remove_wish(query) {
return Err(CliError::no_results(format!(
"'{}' is not on the wishlist",
query.trim()
)));
}
store.save(&updated)?;
out.status(&format!("no longer wishing for '{}'", query.trim()));
Ok(())
}
pub fn list(out: &Out, store: &Store) -> CliResult {
let wishes = store.config.wishes();
if wishes.is_empty() {
return Err(CliError::no_results("the wishlist is empty"));
}
for query in wishes {
out.emit(&WishRecord { query });
}
Ok(())
}
pub fn run(ctx: &Ctx, store: &Store, args: &WishRunArgs) -> CliResult {
let wishes = selected(&store.config, args.only.as_deref())?;
let session = Session::open(ctx)?;
if !sweep(ctx, &session.client, &wishes, &Sweep::from(args)) {
return Err(CliError::no_results(
"no wish matched anything on the network",
));
}
Ok(())
}
/// How to narrow and order what a sweep prints — the parts of `wish run`'s flags
/// that outlive the command, so `serve --wishlist` can sweep without inventing
/// a set of command-line arguments it never received.
struct Sweep {
filter: transfer::Filter,
sort: SortKey,
limit: usize,
}
impl Default for Sweep {
fn default() -> Self {
Self {
filter: transfer::Filter::default(),
sort: SortKey::Best,
limit: 0,
}
}
}
impl From<&WishRunArgs> for Sweep {
fn from(args: &WishRunArgs) -> Self {
Self {
filter: transfer::Filter::from(&args.filter),
sort: args.sort,
limit: args.limit,
}
}
}
/// Re-run the wishlist for as long as `serve` stays online, on the interval the
/// server set. Called from the serve loop rather than owning it, so uploads keep
/// being reported between sweeps.
pub struct Sweeper {
wishes: Vec<String>,
next: Instant,
}
impl Sweeper {
/// `None` when there is nothing to look for, so `serve` can say so once
/// instead of waking up forever with an empty list.
#[must_use]
pub fn new(wishes: Vec<String>) -> Option<Self> {
(!wishes.is_empty()).then(|| Self {
wishes,
// The first sweep is immediate: a wishlist that waits twelve
// minutes before its first look is indistinguishable from broken.
next: Instant::now(),
})
}
/// Search every wish if the interval has elapsed. Does nothing otherwise,
/// so the caller can poll it as often as it likes.
///
/// this blocks the serve loop for one search window, so uploads go
/// unreported and `--duration` overshoots by that much. Move the sweep to its
/// own thread with a channel of records if that ever matters; what mattered
/// was that it used to be one window *per wish*.
pub fn tick(&mut self, ctx: &Ctx, client: &Client) {
if Instant::now() < self.next {
return;
}
sweep(ctx, client, &self.wishes, &Sweep::default());
let interval = client.wishlist_interval();
self.next = Instant::now() + interval;
ctx.out
.status(&format!("next wishlist sweep in {}s", interval.as_secs()));
}
}
/// Search every wish and print what survived the filters. Returns whether
/// anything did.
///
/// All the searches go out before anything is waited for: peers answer whenever
/// they get round to it, so one shared window covers every wish, where waiting
/// per wish would cost a full window each.
fn sweep(ctx: &Ctx, client: &Client, wishes: &[String], how: &Sweep) -> bool {
let mut sent = Vec::new();
for wish in wishes {
match client.start_wishlist_search(wish) {
Ok(()) => sent.push(wish),
Err(e) => ctx
.out
.warn(&format!("'{wish}' could not be searched: {e}")),
}
}
if sent.is_empty() {
return false;
}
ctx.out.status(&format!(
"wishing for {} ({}s)…",
sent.iter()
.map(|w| format!("'{w}'"))
.collect::<Vec<_>>()
.join(", "),
ctx.search_timeout.as_secs()
));
Client::collect_for(ctx.search_timeout, None);
let mut found_any = false;
for wish in sent {
let results = client.get_search_results(wish);
let ranked =
transfer::ranked(&results, &how.filter, how.sort, how.limit, wish);
let Ok(found) = ranked else {
ctx.out.status(&format!("nothing for '{wish}' this time"));
continue;
};
found_any = true;
for candidate in &found {
ctx.out.emit(candidate);
}
}
found_any
}
/// Which wishes a run covers. `--only` has to name a stored wish: letting a
/// typo through would quietly search for something the user never asked to
/// keep, and report it as a wishlist hit.
///
/// Matching goes through [`FileConfig::wish`], so a query that `wish add` would
/// call a duplicate is one `--only` can select.
fn selected(config: &FileConfig, only: Option<&str>) -> CliResult<Vec<String>> {
let Some(only) = only else {
let wishes = config.wishes();
if wishes.is_empty() {
return Err(CliError::no_results(
"the wishlist is empty — add one with `wish add QUERY`",
));
}
return Ok(wishes);
};
config
.wish(only)
.map(|wish| vec![wish.clone()])
.ok_or_else(|| {
CliError::usage(format!(
"'{}' is not on the wishlist; `wish list` shows what is",
only.trim()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::Exit;
fn stored() -> FileConfig {
let mut config = FileConfig::default();
config.add_wish("Aphex Twin");
config.add_wish("Boards of Canada");
config
}
#[test]
fn without_only_every_stored_wish_runs() {
assert_eq!(selected(&stored(), None).unwrap(), stored().wishes());
}
#[test]
fn an_empty_wishlist_is_the_no_results_verdict() {
let error =
selected(&FileConfig::default(), None).expect_err("nothing to run");
assert_eq!(error.exit, Exit::NoResults);
}
#[test]
fn only_selects_one_stored_wish_ignoring_case_and_space() {
assert_eq!(
selected(&stored(), Some(" aphex twin ")).unwrap(),
["Aphex Twin"],
"the stored spelling is what gets searched"
);
}
#[test]
fn only_naming_something_unstored_is_a_usage_error() {
let error = selected(&stored(), Some("plaid"))
.expect_err("a typo must not become an ad-hoc search");
assert_eq!(error.exit, Exit::Usage);
assert!(error.message.contains("wish list"), "{}", error.message);
}
#[test]
fn a_sweeper_needs_something_to_look_for() {
assert!(Sweeper::new(Vec::new()).is_none());
assert!(Sweeper::new(stored().wishes()).is_some());
}
}