feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a human at the keyboard: `search` opened a full-screen picker, panicked with exit 101 when there was no terminal, and no command could fetch a known file at all. Everything exited 0 whatever happened, wrote emoji prose to stdout, and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml. The commands are now built for scripts and agents: search download get browse room list|say|listen message send|read stdout carries records only (tab-separated, or NDJSON under --json), stderr carries progress, and the exit code carries the verdict: 2 usage, 3 connect, 4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and fetch in one step, and `search --json | jq ... | download --stdin` composes. Global flags parse in any position and every one has a SOULSEEK_ variable and a config.toml key resolved through a single precedence chain. Defects found and fixed along the way, each covered by a test: - a download with an unknown size was finalized against zero bytes and saved as an empty file; the size now comes from the peer's share listing - transfers are keyed by a hash of the remote path, so the same path from two peers shadowed itself and two files sharing a basename overwrote each other; both collisions are now dropped with a warning - `message send` and `room say` slept 500ms and claimed success; they now wait for a server reply queued behind the message before reporting - Client::login waited on an unbounded channel and never returned when the server went quiet Deleted the TUI picker the old `search` drove (download_selector, MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The interactive TUI is otherwise untouched. BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and `message send|read`; `--disable-listener` becomes `--no-listener`; the unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no longer opens a picker or downloads anything.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
//! One-shot commands: everything the binary can do without a terminal.
|
||||
//!
|
||||
//! Each command opens a [`Session`], does one job, writes records to stdout
|
||||
//! and progress to stderr, and returns an exit code through [`CliError`].
|
||||
|
||||
pub mod social;
|
||||
pub mod transfer;
|
||||
|
||||
use crate::cli::{Commands, MessageCommand, RoomCommand};
|
||||
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
|
||||
use crate::port_mapping::{self, PortMapper};
|
||||
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
||||
use soulseek_rs::{Client, ClientSettings};
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Everything a one-shot command needs that does not come from its own flags.
|
||||
pub struct Ctx {
|
||||
pub out: Out,
|
||||
pub settings: ClientSettings,
|
||||
pub download_dir: String,
|
||||
pub max_concurrent_downloads: usize,
|
||||
pub search_timeout: Duration,
|
||||
}
|
||||
|
||||
/// A logged-in client plus, while the listener is enabled, the port mapping
|
||||
/// that makes us reachable. Both live until the command ends.
|
||||
pub struct Session {
|
||||
pub client: Arc<Client>,
|
||||
_mapper: Option<PortMapper>,
|
||||
}
|
||||
|
||||
/// How long to wait for the TCP handshake with the server.
|
||||
const REACH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How long to wait for the login exchange once the socket is open.
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
impl Session {
|
||||
pub fn open(ctx: &Ctx) -> CliResult<Self> {
|
||||
let mapper = ctx
|
||||
.settings
|
||||
.enable_listen
|
||||
.then(|| PortMapper::spawn(ctx.settings.listen_port));
|
||||
|
||||
let address = ctx.settings.server_address.to_string();
|
||||
ctx.out.status(&format!("connecting to {address}…"));
|
||||
reachable(&address, REACH_TIMEOUT)?;
|
||||
|
||||
let client = login(ctx.settings.clone(), &address)?;
|
||||
ctx.out
|
||||
.status(&format!("logged in as {}", ctx.settings.username));
|
||||
|
||||
Ok(Self {
|
||||
client: Arc::new(client),
|
||||
_mapper: mapper,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the server answers at all before handing the socket to the client.
|
||||
///
|
||||
/// The connect and login the library performs happen on its own threads and
|
||||
/// report a refused connection only indirectly, so an unattended run would sit
|
||||
/// there. One handshake up front turns that into an immediate, accurate error.
|
||||
fn reachable(address: &str, timeout: Duration) -> CliResult {
|
||||
let resolved: Vec<SocketAddr> = address
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| {
|
||||
CliError::connection(format!("cannot resolve {address}: {e}"))
|
||||
})?
|
||||
.collect();
|
||||
|
||||
let mut last = None;
|
||||
for candidate in &resolved {
|
||||
match TcpStream::connect_timeout(candidate, timeout) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(CliError::connection(match last {
|
||||
Some(e) => format!("cannot reach {address}: {e}"),
|
||||
None => format!("cannot reach {address}: no address resolved"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Connect and log in under a deadline.
|
||||
///
|
||||
/// `Client::login` blocks on a channel the library only answers once its actor
|
||||
/// has a working connection, so it is run on its own thread: a server that
|
||||
/// accepts the socket and then says nothing must still end the command.
|
||||
fn login(settings: ClientSettings, address: &str) -> CliResult<Client> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut client = Client::with_settings(settings);
|
||||
let outcome = match client.connect() {
|
||||
Ok(()) => client.login(),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let _ = sender.send((client, outcome));
|
||||
});
|
||||
|
||||
let (client, outcome) =
|
||||
receiver.recv_timeout(LOGIN_TIMEOUT).map_err(|_| {
|
||||
CliError::connection(format!(
|
||||
"{address} accepted the connection but did not answer the \
|
||||
login within {}s",
|
||||
LOGIN_TIMEOUT.as_secs()
|
||||
))
|
||||
})?;
|
||||
|
||||
match outcome {
|
||||
Ok(true) => Ok(client),
|
||||
Ok(false) => Err(CliError::connection(
|
||||
"login rejected by the server (wrong password, or the name is \
|
||||
taken)",
|
||||
)),
|
||||
Err(e) => Err(CliError::connection(format!("login failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the room list and wait for the server's reply.
|
||||
///
|
||||
/// Events already queued are discarded first: servers push a room list at
|
||||
/// login, and counting that one would answer a question we had not asked yet.
|
||||
pub fn await_room_list(
|
||||
client: &Client,
|
||||
timeout: Duration,
|
||||
poll: Duration,
|
||||
) -> Option<Vec<RoomInfo>> {
|
||||
let _ = client.take_room_events();
|
||||
client.request_room_list().ok()?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
let listed =
|
||||
client.take_room_events().into_iter().find_map(
|
||||
|event| match event {
|
||||
RoomEvent::List(rooms) => Some(rooms),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
if listed.is_some() {
|
||||
return listed;
|
||||
}
|
||||
std::thread::sleep(poll);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Wait until the server has answered something we asked for after `send`.
|
||||
///
|
||||
/// The server connection is a single ordered stream served by one actor, so a
|
||||
/// reply to a request queued *after* an earlier message proves that earlier
|
||||
/// message reached the socket. The room list is the cheapest such request and
|
||||
/// every server answers it, which lets `message send` and `room say` report
|
||||
/// delivery honestly instead of sleeping and hoping.
|
||||
pub fn confirm_flush(client: &Client, timeout: Duration) -> bool {
|
||||
await_room_list(client, timeout, Duration::from_millis(50)).is_some()
|
||||
}
|
||||
|
||||
pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
|
||||
match command {
|
||||
Commands::Search(args) => transfer::search(ctx, &args),
|
||||
Commands::Download(args) => transfer::download(ctx, &args),
|
||||
Commands::Get(args) => transfer::get(ctx, &args),
|
||||
Commands::Browse(args) => social::browse(ctx, &args),
|
||||
Commands::Room(RoomCommand::List { timeout }) => {
|
||||
social::room_list(ctx, Duration::from_secs(timeout))
|
||||
}
|
||||
Commands::Room(RoomCommand::Say { room, message }) => {
|
||||
social::room_say(ctx, &room, &message)
|
||||
}
|
||||
Commands::Room(RoomCommand::Listen {
|
||||
room,
|
||||
duration,
|
||||
follow,
|
||||
}) => social::room_listen(ctx, &room, listen_span(duration, follow)),
|
||||
Commands::Message(MessageCommand::Send { user, message }) => {
|
||||
social::message_send(ctx, &user, &message)
|
||||
}
|
||||
Commands::Message(MessageCommand::Read { duration, follow }) => {
|
||||
social::message_read(ctx, listen_span(duration, follow))
|
||||
}
|
||||
// Handled before credentials are required; see main.rs.
|
||||
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--follow` means "until interrupted": a span nothing will outlive.
|
||||
fn listen_span(duration: u64, follow: bool) -> Option<Duration> {
|
||||
(!follow).then(|| Duration::from_secs(duration))
|
||||
}
|
||||
|
||||
/// Probe automatic port mapping. A router that does not answer is a negative
|
||||
/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match.
|
||||
pub fn portmap(out: &Out, port: u16) -> CliResult {
|
||||
out.status(&format!("probing automatic port mapping for TCP {port}…"));
|
||||
let probe = port_mapping::probe(port);
|
||||
out.emit(&PortmapRecord {
|
||||
ok: probe.ok,
|
||||
backend: probe.backend.to_string(),
|
||||
external: probe.external.clone(),
|
||||
port,
|
||||
});
|
||||
out.status(&probe.advice);
|
||||
if probe.ok {
|
||||
Ok(())
|
||||
} else {
|
||||
// The advice is already on stderr; the error line only has to say
|
||||
// what the exit code means.
|
||||
Err(CliError::new(
|
||||
Exit::NoResults,
|
||||
format!("no router mapped TCP {port}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::listen_span;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn follow_listens_without_a_deadline() {
|
||||
assert_eq!(listen_span(30, true), None);
|
||||
assert_eq!(listen_span(30, false), Some(Duration::from_secs(30)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Talking to people and looking at what they share: `browse`, `room`, and
|
||||
//! `message`.
|
||||
|
||||
use super::{Ctx, Session, confirm_flush};
|
||||
use crate::cli::BrowseArgs;
|
||||
use crate::output::{
|
||||
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
|
||||
RoomEventRecord, RoomRecord,
|
||||
};
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
use soulseek_rs::{Client, SharedDirectory};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long to wait for the server to acknowledge something we sent.
|
||||
const FLUSH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Polling cadence for the request/poll parts of the client API.
|
||||
const POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Join a listing's directory and file name into the remote path that
|
||||
/// `download` expects — the protocol reports the two separately.
|
||||
#[must_use]
|
||||
pub fn full_path(directory: &str, basename: &str) -> String {
|
||||
let directory = directory.trim_end_matches('\\');
|
||||
if directory.is_empty() {
|
||||
basename.to_string()
|
||||
} else {
|
||||
format!("{directory}\\{basename}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a peer for its share listing and wait for the answer.
|
||||
pub fn fetch_listing(
|
||||
client: &Client,
|
||||
user: &str,
|
||||
timeout: Duration,
|
||||
) -> CliResult<Vec<SharedDirectory>> {
|
||||
client.browse_user(user).map_err(|e| {
|
||||
CliError::connection(format!("cannot ask {user} for a listing: {e}"))
|
||||
})?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(directories) = client.take_browse_result(user) {
|
||||
return Ok(directories);
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
Err(CliError::timeout(format!(
|
||||
"{user} did not send a file list within {}s",
|
||||
timeout.as_secs()
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
ctx.out
|
||||
.status(&format!("requesting {}'s shared files…", args.user));
|
||||
|
||||
let directories = fetch_listing(
|
||||
&session.client,
|
||||
&args.user,
|
||||
Duration::from_secs(args.timeout),
|
||||
)?;
|
||||
|
||||
let mut files = 0usize;
|
||||
for directory in &directories {
|
||||
for (basename, size) in &directory.files {
|
||||
files += 1;
|
||||
ctx.out.emit(&BrowseRecord {
|
||||
user: args.user.clone(),
|
||||
directory: directory.name.clone(),
|
||||
path: full_path(&directory.name, basename),
|
||||
size: *size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if files == 0 {
|
||||
return Err(CliError::no_results(format!(
|
||||
"{} shares no files",
|
||||
args.user
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn room_list(ctx: &Ctx, timeout: Duration) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
ctx.out.status("fetching the room list…");
|
||||
|
||||
let Some(mut rooms) =
|
||||
super::await_room_list(&session.client, timeout, POLL)
|
||||
else {
|
||||
return Err(CliError::timeout(format!(
|
||||
"the server sent no room list within {}s",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
};
|
||||
if rooms.is_empty() {
|
||||
return Err(CliError::no_results("the server lists no public rooms"));
|
||||
}
|
||||
|
||||
rooms.sort_by(|a, b| {
|
||||
b.user_count
|
||||
.cmp(&a.user_count)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
for room in rooms {
|
||||
ctx.out.emit(&RoomRecord {
|
||||
room: room.name,
|
||||
users: room.user_count,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
session.client.join_room(room).map_err(|e| {
|
||||
CliError::connection(format!("cannot join {room}: {e}"))
|
||||
})?;
|
||||
session.client.say_in_room(room, message).map_err(|e| {
|
||||
CliError::connection(format!("cannot post to {room}: {e}"))
|
||||
})?;
|
||||
|
||||
if confirm_flush(&session.client, FLUSH_TIMEOUT) {
|
||||
ctx.out.status(&format!("posted to {room}"));
|
||||
return Ok(());
|
||||
}
|
||||
Err(CliError::timeout(format!(
|
||||
"the server did not acknowledge the message to {room}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn room_listen(ctx: &Ctx, room: &str, span: Option<Duration>) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
session.client.join_room(room).map_err(|e| {
|
||||
CliError::connection(format!("cannot join {room}: {e}"))
|
||||
})?;
|
||||
|
||||
stream(&ctx.out, &format!("listening to {room}"), span, || {
|
||||
for event in session.client.take_room_events() {
|
||||
if let Some(record) = room_event_record(room, &event) {
|
||||
ctx.out.emit(&record);
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = session.client.leave_room(room);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain `next` until `span` elapses, or forever when it is `None`
|
||||
/// (`--follow`, ended by the operator).
|
||||
fn stream(
|
||||
out: &Out,
|
||||
what: &str,
|
||||
span: Option<Duration>,
|
||||
mut next: impl FnMut(),
|
||||
) {
|
||||
out.status(&match span {
|
||||
Some(span) => format!("{what} for {}s…", span.as_secs()),
|
||||
None => format!("{what} until interrupted…"),
|
||||
});
|
||||
let deadline = span.map(|span| Instant::now() + span);
|
||||
while deadline.is_none_or(|deadline| Instant::now() < deadline) {
|
||||
next();
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a room event into a record, ignoring events for other rooms and the
|
||||
/// bookkeeping ones a listener has no use for.
|
||||
fn room_event_record(room: &str, event: &RoomEvent) -> Option<RoomEventRecord> {
|
||||
match event {
|
||||
RoomEvent::Message {
|
||||
room: name,
|
||||
username,
|
||||
message,
|
||||
} if name == room => Some(RoomEventRecord {
|
||||
kind: "message",
|
||||
room: name.clone(),
|
||||
user: username.clone(),
|
||||
message: message.clone(),
|
||||
}),
|
||||
RoomEvent::UserJoined {
|
||||
room: name,
|
||||
username,
|
||||
} if name == room => Some(RoomEventRecord {
|
||||
kind: "join",
|
||||
room: name.clone(),
|
||||
user: username.clone(),
|
||||
message: String::new(),
|
||||
}),
|
||||
RoomEvent::UserLeft {
|
||||
room: name,
|
||||
username,
|
||||
} if name == room => Some(RoomEventRecord {
|
||||
kind: "leave",
|
||||
room: name.clone(),
|
||||
user: username.clone(),
|
||||
message: String::new(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_send(ctx: &Ctx, user: &str, message: &str) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
session
|
||||
.client
|
||||
.send_private_message(user, message)
|
||||
.map_err(|e| {
|
||||
CliError::connection(format!("cannot send to {user}: {e}"))
|
||||
})?;
|
||||
|
||||
if confirm_flush(&session.client, FLUSH_TIMEOUT) {
|
||||
ctx.out.status(&format!("sent to {user}"));
|
||||
return Ok(());
|
||||
}
|
||||
Err(CliError::timeout(format!(
|
||||
"the server did not acknowledge the message to {user}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn message_read(ctx: &Ctx, span: Option<Duration>) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
|
||||
stream(&ctx.out, "reading private messages", span, || {
|
||||
for message in session.client.take_private_messages() {
|
||||
ctx.out.emit(&PrivateMessageRecord {
|
||||
timestamp: message.timestamp(),
|
||||
user: message.username().to_string(),
|
||||
message: message.message().to_string(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_path_joins_a_directory_and_basename_with_a_backslash() {
|
||||
assert_eq!(full_path("@@abc\\Music", "a.mp3"), "@@abc\\Music\\a.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_path_does_not_double_a_trailing_separator() {
|
||||
assert_eq!(full_path("@@abc\\Music\\", "a.mp3"), "@@abc\\Music\\a.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_path_of_a_rootless_listing_is_the_basename() {
|
||||
assert_eq!(full_path("", "a.mp3"), "a.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_events_for_the_listened_room_become_records() {
|
||||
let mine = RoomEvent::Message {
|
||||
room: "lobby".into(),
|
||||
username: "bob".into(),
|
||||
message: "hi".into(),
|
||||
};
|
||||
let elsewhere = RoomEvent::Message {
|
||||
room: "other".into(),
|
||||
username: "bob".into(),
|
||||
message: "hi".into(),
|
||||
};
|
||||
assert!(room_event_record("lobby", &mine).is_some());
|
||||
assert!(room_event_record("lobby", &elsewhere).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_and_leaves_are_labelled() {
|
||||
let joined = RoomEvent::UserJoined {
|
||||
room: "lobby".into(),
|
||||
username: "sue".into(),
|
||||
};
|
||||
let left = RoomEvent::UserLeft {
|
||||
room: "lobby".into(),
|
||||
username: "sue".into(),
|
||||
};
|
||||
assert_eq!(room_event_record("lobby", &joined).unwrap().kind, "join");
|
||||
assert_eq!(room_event_record("lobby", &left).unwrap().kind, "leave");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_join_confirmation_is_not_a_record() {
|
||||
let joined = RoomEvent::Joined {
|
||||
room: "lobby".into(),
|
||||
users: vec!["bob".into()],
|
||||
};
|
||||
assert!(room_event_record("lobby", &joined).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
//! Finding files and fetching them: `search`, `download`, and `get`.
|
||||
|
||||
use super::{Ctx, Session, social};
|
||||
use crate::cli::{DownloadArgs, GetArgs, Pick, SearchArgs, SortKey};
|
||||
use crate::output::{
|
||||
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
|
||||
};
|
||||
use soulseek_rs::utils::path::expand_tilde;
|
||||
use soulseek_rs::{Client, DownloadStatus, SearchResult};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Soulseek file attribute codes the network agrees on.
|
||||
const ATTR_BITRATE: u32 = 0;
|
||||
const ATTR_DURATION: u32 = 1;
|
||||
|
||||
/// What to keep out of the raw result set.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Filter {
|
||||
pub min_bitrate: Option<u32>,
|
||||
pub free_slots_only: bool,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
const 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten per-peer results into individual files, dropping what `filter`
|
||||
/// rejects.
|
||||
#[must_use]
|
||||
pub fn candidates(
|
||||
results: &[SearchResult],
|
||||
filter: &Filter,
|
||||
) -> Vec<SearchRecord> {
|
||||
let mut out = Vec::new();
|
||||
for result in results {
|
||||
for file in &result.files {
|
||||
let candidate = SearchRecord {
|
||||
user: result.username.clone(),
|
||||
path: file.name.clone(),
|
||||
size: file.size,
|
||||
bitrate: file.attribs.get(&ATTR_BITRATE).copied(),
|
||||
duration: file.attribs.get(&ATTR_DURATION).copied(),
|
||||
free_slot: result.slots > 0,
|
||||
speed: result.speed,
|
||||
};
|
||||
if filter.keeps(&candidate) {
|
||||
out.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Order results. Every mode ends with the same user/path tiebreak so the
|
||||
/// output of two identical runs is identical.
|
||||
pub fn rank(items: &mut [SearchRecord], sort: SortKey) {
|
||||
items.sort_by(|a, b| {
|
||||
let primary = match sort {
|
||||
SortKey::Best => b
|
||||
.free_slot
|
||||
.cmp(&a.free_slot)
|
||||
.then_with(|| {
|
||||
b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0))
|
||||
})
|
||||
.then_with(|| b.speed.cmp(&a.speed))
|
||||
.then_with(|| b.size.cmp(&a.size)),
|
||||
SortKey::Size => b.size.cmp(&a.size),
|
||||
SortKey::Bitrate => {
|
||||
b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0))
|
||||
}
|
||||
SortKey::Speed => b.speed.cmp(&a.speed),
|
||||
};
|
||||
primary
|
||||
.then_with(|| a.user.cmp(&b.user))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
}
|
||||
|
||||
/// Where a remote file lands locally — the same rule the library applies, so
|
||||
/// the path we report is the path that gets written.
|
||||
#[must_use]
|
||||
pub fn local_path(download_dir: &str, remote_path: &str) -> PathBuf {
|
||||
let name = remote_path
|
||||
.split(['/', '\\'])
|
||||
.next_back()
|
||||
.unwrap_or(remote_path);
|
||||
expand_tilde(download_dir).join(name)
|
||||
}
|
||||
|
||||
/// One file to fetch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
pub user: String,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl From<&SearchRecord> for Request {
|
||||
fn from(candidate: &SearchRecord) -> Self {
|
||||
Self {
|
||||
user: candidate.user.clone(),
|
||||
path: candidate.path.clone(),
|
||||
size: candidate.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter, rank, and cap a raw result set, failing when nothing survives.
|
||||
fn ranked(
|
||||
results: &[SearchResult],
|
||||
filter: &Filter,
|
||||
sort: SortKey,
|
||||
limit: usize,
|
||||
query: &str,
|
||||
) -> CliResult<Vec<SearchRecord>> {
|
||||
let mut found = candidates(results, filter);
|
||||
rank(&mut found, sort);
|
||||
if limit > 0 {
|
||||
found.truncate(limit);
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(CliError::no_results(format!("no results for '{query}'")));
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
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,
|
||||
},
|
||||
args.sort,
|
||||
args.limit,
|
||||
&args.query,
|
||||
)?;
|
||||
|
||||
for candidate in &found {
|
||||
ctx.out.emit(candidate);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult {
|
||||
let requests = if args.stdin {
|
||||
let stdin = std::io::stdin();
|
||||
read_requests(&mut stdin.lock(), &ctx.out)?
|
||||
} else {
|
||||
let (Some(user), Some(path)) = (args.user.clone(), args.path.clone())
|
||||
else {
|
||||
return Err(CliError::usage(
|
||||
"download needs USER and PATH, or --stdin",
|
||||
));
|
||||
};
|
||||
vec![Request {
|
||||
user,
|
||||
path,
|
||||
size: args.size.unwrap_or(0),
|
||||
}]
|
||||
};
|
||||
|
||||
if requests.is_empty() {
|
||||
return Err(CliError::no_results("no files to download"));
|
||||
}
|
||||
|
||||
ensure_download_dir(&ctx.download_dir)?;
|
||||
let session = Session::open(ctx)?;
|
||||
let requests = resolve_sizes(ctx, &session.client, requests)?;
|
||||
download_all(
|
||||
ctx,
|
||||
&session.client,
|
||||
requests,
|
||||
Duration::from_secs(args.timeout),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult {
|
||||
ensure_download_dir(&ctx.download_dir)?;
|
||||
let session = Session::open(ctx)?;
|
||||
let results =
|
||||
collect(ctx, &session.client, &args.query, args.pick == Pick::First)?;
|
||||
|
||||
let limit = match args.pick {
|
||||
Pick::Best | Pick::First => 1,
|
||||
Pick::All => args.limit,
|
||||
};
|
||||
let found = ranked(
|
||||
&results,
|
||||
&Filter {
|
||||
min_bitrate: args.min_bitrate,
|
||||
free_slots_only: args.free_slots,
|
||||
},
|
||||
SortKey::Best,
|
||||
limit,
|
||||
&args.query,
|
||||
)?;
|
||||
|
||||
download_all(
|
||||
ctx,
|
||||
&session.client,
|
||||
found.iter().map(Request::from).collect(),
|
||||
Duration::from_secs(args.timeout),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a search and hand back the raw per-peer results.
|
||||
///
|
||||
/// `stop_early` returns as soon as anything arrives, which is what
|
||||
/// `get --pick first` wants; otherwise the search runs its full span because
|
||||
/// late responders are often the better sources.
|
||||
fn collect(
|
||||
ctx: &Ctx,
|
||||
client: &Arc<Client>,
|
||||
query: &str,
|
||||
stop_early: bool,
|
||||
) -> CliResult<Vec<SearchResult>> {
|
||||
let timeout = ctx.search_timeout;
|
||||
ctx.out.status(&format!(
|
||||
"searching for '{query}' ({}s)…",
|
||||
timeout.as_secs()
|
||||
));
|
||||
|
||||
if !stop_early {
|
||||
return client
|
||||
.search(query, timeout)
|
||||
.map_err(|e| CliError::connection(format!("search failed: {e}")));
|
||||
}
|
||||
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let worker = {
|
||||
let client = Arc::clone(client);
|
||||
let query = query.to_string();
|
||||
let cancel = Arc::clone(&cancel);
|
||||
std::thread::spawn(move || {
|
||||
client.search_with_cancel(&query, timeout, Some(cancel))
|
||||
})
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if client.get_search_results_count(query) > 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
let _ = worker.join();
|
||||
Ok(client.get_search_results(query))
|
||||
}
|
||||
|
||||
/// Drop requests that would collide with one already queued.
|
||||
///
|
||||
/// Two collisions are possible and both lose data silently: the library keys
|
||||
/// downloads by a hash of the remote path, so the same path from two peers
|
||||
/// shadows itself, and two different paths sharing a basename would be written
|
||||
/// to the same local file. Keep the first (best-ranked) of each and say what
|
||||
/// was dropped rather than transferring less than asked.
|
||||
fn dedupe(
|
||||
requests: impl IntoIterator<Item = Request>,
|
||||
download_dir: &str,
|
||||
out: &Out,
|
||||
) -> Vec<Request> {
|
||||
let mut paths: HashMap<String, String> = HashMap::new();
|
||||
let mut targets: HashMap<PathBuf, String> = HashMap::new();
|
||||
let mut kept = Vec::new();
|
||||
for request in requests {
|
||||
if let Some(user) = paths.get(&request.path) {
|
||||
out.warn(&format!(
|
||||
"skipping {} from {}: same remote path already queued from \
|
||||
{user}",
|
||||
request.path, request.user
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = local_path(download_dir, &request.path);
|
||||
if let Some(other) = targets.get(&target) {
|
||||
out.warn(&format!(
|
||||
"skipping {} from {}: would overwrite the file already queued \
|
||||
from {other}",
|
||||
request.path, request.user
|
||||
));
|
||||
continue;
|
||||
}
|
||||
paths.insert(request.path.clone(), request.user.clone());
|
||||
targets.insert(target, request.user.clone());
|
||||
kept.push(request);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// A transfer whose size we do not know would be finalized against zero bytes
|
||||
/// and saved empty, so look it up in the peer's share listing first.
|
||||
fn resolve_sizes(
|
||||
ctx: &Ctx,
|
||||
client: &Client,
|
||||
requests: Vec<Request>,
|
||||
) -> CliResult<Vec<Request>> {
|
||||
if requests.iter().all(|request| request.size > 0) {
|
||||
return Ok(requests);
|
||||
}
|
||||
|
||||
let mut sizes: HashMap<String, HashMap<String, u64>> = HashMap::new();
|
||||
let mut resolved = Vec::with_capacity(requests.len());
|
||||
for mut request in requests {
|
||||
if request.size > 0 {
|
||||
resolved.push(request);
|
||||
continue;
|
||||
}
|
||||
if !sizes.contains_key(&request.user) {
|
||||
ctx.out.status(&format!(
|
||||
"looking up the size of {} in {}'s shares…",
|
||||
request.path, request.user
|
||||
));
|
||||
let listing = social::fetch_listing(
|
||||
client,
|
||||
&request.user,
|
||||
Duration::from_secs(20),
|
||||
)?;
|
||||
sizes.insert(
|
||||
request.user.clone(),
|
||||
listing
|
||||
.iter()
|
||||
.flat_map(|directory| {
|
||||
directory.files.iter().map(|(basename, size)| {
|
||||
(
|
||||
social::full_path(&directory.name, basename),
|
||||
*size,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
request.size = sizes
|
||||
.get(&request.user)
|
||||
.and_then(|files| files.get(&request.path))
|
||||
.copied()
|
||||
.ok_or_else(|| {
|
||||
CliError::no_results(format!(
|
||||
"{} does not share '{}' — pass --size to download it \
|
||||
anyway",
|
||||
request.user, request.path
|
||||
))
|
||||
})?;
|
||||
resolved.push(request);
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn ensure_download_dir(download_dir: &str) -> CliResult {
|
||||
let path = expand_tilde(download_dir);
|
||||
std::fs::create_dir_all(&path).map_err(|e| {
|
||||
CliError::usage(format!(
|
||||
"cannot use download directory {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch every request, at most `max_concurrent_downloads` at a time, emitting
|
||||
/// each result as it lands. Any failure fails the command.
|
||||
fn download_all(
|
||||
ctx: &Ctx,
|
||||
client: &Arc<Client>,
|
||||
requests: Vec<Request>,
|
||||
timeout: Duration,
|
||||
) -> CliResult {
|
||||
let requests = dedupe(requests, &ctx.download_dir, &ctx.out);
|
||||
let workers = ctx.max_concurrent_downloads.clamp(1, requests.len().max(1));
|
||||
let queue = Mutex::new(VecDeque::from(requests));
|
||||
let (sender, receiver) = channel();
|
||||
|
||||
let mut failures = std::thread::scope(|scope| {
|
||||
for _ in 0..workers {
|
||||
let sender = sender.clone();
|
||||
let queue = &queue;
|
||||
scope.spawn(move || {
|
||||
while let Some(request) =
|
||||
queue.lock().ok().and_then(|mut q| q.pop_front())
|
||||
{
|
||||
let outcome = download_one(
|
||||
client,
|
||||
&ctx.out,
|
||||
&request,
|
||||
&ctx.download_dir,
|
||||
timeout,
|
||||
);
|
||||
if sender.send((request, outcome)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(sender);
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for (request, outcome) in receiver {
|
||||
match outcome {
|
||||
Ok(record) => ctx.out.emit(&record),
|
||||
Err(error) => {
|
||||
ctx.out.error(&format!("{}: {error}", request.path));
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
});
|
||||
|
||||
if failures.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if failures.len() == 1 {
|
||||
return Err(failures.remove(0));
|
||||
}
|
||||
let n = failures.len();
|
||||
if failures.iter().all(|f| f.exit == Exit::Timeout) {
|
||||
return Err(CliError::new(
|
||||
Exit::Timeout,
|
||||
format!("{n} downloads timed out"),
|
||||
));
|
||||
}
|
||||
Err(CliError::transfer(format!("{n} downloads failed")))
|
||||
}
|
||||
|
||||
/// Queue one transfer and watch it to a verdict. The library reports failure
|
||||
/// on the status channel rather than through the call that starts the
|
||||
/// transfer, so the channel is the only thing worth believing.
|
||||
fn download_one(
|
||||
client: &Client,
|
||||
out: &Out,
|
||||
request: &Request,
|
||||
download_dir: &str,
|
||||
timeout: Duration,
|
||||
) -> CliResult<DownloadRecord> {
|
||||
// Clear any earlier attempt: entries are keyed by a hash of the remote
|
||||
// path, so a stale one would shadow this transfer.
|
||||
let _ = client.remove_download(&request.user, &request.path);
|
||||
|
||||
let (_download, updates) = client
|
||||
.download(
|
||||
request.path.clone(),
|
||||
request.user.clone(),
|
||||
request.size,
|
||||
download_dir.to_string(),
|
||||
)
|
||||
.map_err(|e| CliError::transfer(format!("cannot start: {e}")))?;
|
||||
|
||||
out.status(&format!(
|
||||
"requesting {} from {} ({} bytes)…",
|
||||
request.path, request.user, request.size
|
||||
));
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut last_report = Instant::now();
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
let _ = client.remove_download(&request.user, &request.path);
|
||||
return Err(CliError::timeout(format!(
|
||||
"gave up after {}s",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
match updates.recv_timeout(remaining.min(Duration::from_millis(250))) {
|
||||
Ok(DownloadStatus::Completed) => {
|
||||
let file = local_path(download_dir, &request.path);
|
||||
return Ok(DownloadRecord {
|
||||
user: request.user.clone(),
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
file: file.display().to_string(),
|
||||
});
|
||||
}
|
||||
Ok(DownloadStatus::Failed(reason)) => {
|
||||
let _ = client.remove_download(&request.user, &request.path);
|
||||
return Err(CliError::transfer(
|
||||
reason.unwrap_or_else(|| "the transfer failed".to_string()),
|
||||
));
|
||||
}
|
||||
Ok(DownloadStatus::Queued) => {
|
||||
out.status(&format!(
|
||||
"queued behind {}'s uploads",
|
||||
request.user
|
||||
));
|
||||
}
|
||||
Ok(DownloadStatus::InProgress {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bytes_per_sec,
|
||||
}) => {
|
||||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||||
last_report = Instant::now();
|
||||
out.status(&format!(
|
||||
"{}: {bytes_downloaded}/{total_bytes} bytes ({:.0} KB/s)",
|
||||
request.path,
|
||||
speed_bytes_per_sec / 1024.0
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(DownloadStatus::Paused { .. } | DownloadStatus::TimedOut)
|
||||
| Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
let _ = client.remove_download(&request.user, &request.path);
|
||||
return Err(CliError::transfer(
|
||||
"the transfer ended without reporting a result",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `search`/`browse` output back into download requests.
|
||||
///
|
||||
/// Both shapes this program emits are accepted: a JSON object per line, or
|
||||
/// tab-separated text whose first field is the user and last field is the
|
||||
/// remote path, with the size in between when there is one.
|
||||
fn read_requests(
|
||||
input: &mut impl BufRead,
|
||||
out: &Out,
|
||||
) -> CliResult<Vec<Request>> {
|
||||
let mut requests = Vec::new();
|
||||
for (number, line) in input.lines().enumerate() {
|
||||
let line = line
|
||||
.map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?;
|
||||
match parse_request(&line) {
|
||||
Ok(Some(request)) => requests.push(request),
|
||||
Ok(None) => {}
|
||||
Err(reason) => {
|
||||
out.warn(&format!("stdin line {}: {reason}", number + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
/// `Ok(None)` for a blank line, `Err` for something unusable.
|
||||
fn parse_request(line: &str) -> Result<Option<Request>, String> {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (user, path, size) = if line.trim_start().starts_with('{') {
|
||||
let record: JsonRequest = serde_json::from_str(line.trim())
|
||||
.map_err(|e| format!("invalid JSON record: {e}"))?;
|
||||
(record.user, record.path, record.size)
|
||||
} else {
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
if fields.len() < 2 {
|
||||
return Err(
|
||||
"expected tab-separated USER…PATH or a JSON record".to_string()
|
||||
);
|
||||
}
|
||||
let size = if fields.len() >= 3 {
|
||||
fields[1].trim().parse().unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(
|
||||
fields[0].trim().to_string(),
|
||||
fields[fields.len() - 1].trim().to_string(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
|
||||
if user.is_empty() || path.is_empty() {
|
||||
return Err("user and path must not be empty".to_string());
|
||||
}
|
||||
Ok(Some(Request { user, path, size }))
|
||||
}
|
||||
|
||||
/// The subset of a `search`/`browse` JSON record a download needs. Extra
|
||||
/// fields are ignored, so a record enriched by `jq` still parses.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JsonRequest {
|
||||
user: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soulseek_rs::File;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn file(name: &str, size: u64, bitrate: Option<u32>) -> File {
|
||||
let mut attribs = HashMap::new();
|
||||
if let Some(bitrate) = bitrate {
|
||||
attribs.insert(ATTR_BITRATE, bitrate);
|
||||
attribs.insert(ATTR_DURATION, 200);
|
||||
}
|
||||
File {
|
||||
username: String::new(),
|
||||
name: name.to_string(),
|
||||
size,
|
||||
attribs,
|
||||
}
|
||||
}
|
||||
|
||||
fn result(
|
||||
user: &str,
|
||||
slots: u8,
|
||||
speed: u32,
|
||||
files: Vec<File>,
|
||||
) -> SearchResult {
|
||||
SearchResult {
|
||||
token: 1,
|
||||
files,
|
||||
slots,
|
||||
speed,
|
||||
username: user.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample() -> Vec<SearchResult> {
|
||||
vec![
|
||||
result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]),
|
||||
result("busy", 0, 900, vec![file("b\\best.mp3", 90, Some(320))]),
|
||||
result(
|
||||
"fast",
|
||||
1,
|
||||
500,
|
||||
vec![
|
||||
file("c\\good.mp3", 50, Some(256)),
|
||||
file("c\\unknown.mp3", 70, None),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_flatten_every_file_of_every_peer() {
|
||||
let found = candidates(&sample(), &Filter::default());
|
||||
assert_eq!(found.len(), 4);
|
||||
assert!(found.iter().any(|c| c.user == "fast" && c.size == 70));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_carry_bitrate_duration_and_slot_state() {
|
||||
let found = candidates(&sample(), &Filter::default());
|
||||
let best = found.iter().find(|c| c.path == "b\\best.mp3").unwrap();
|
||||
assert_eq!(best.bitrate, Some(320));
|
||||
assert_eq!(best.duration, Some(200));
|
||||
assert!(!best.free_slot);
|
||||
assert_eq!(best.speed, 900);
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_slots_only_keeps_peers_advertising_a_slot() {
|
||||
let filter = Filter {
|
||||
min_bitrate: None,
|
||||
free_slots_only: true,
|
||||
};
|
||||
let found = candidates(&sample(), &filter);
|
||||
assert!(found.iter().all(|c| c.free_slot));
|
||||
assert!(found.iter().all(|c| c.user != "busy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_prefers_a_free_slot_over_a_higher_bitrate() {
|
||||
let mut found = candidates(&sample(), &Filter::default());
|
||||
rank(&mut found, SortKey::Best);
|
||||
assert_eq!(found[0].path, "c\\good.mp3");
|
||||
assert_eq!(found[0].user, "fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_modes_lead_with_their_own_key() {
|
||||
let mut found = candidates(&sample(), &Filter::default());
|
||||
rank(&mut found, SortKey::Size);
|
||||
assert_eq!(found[0].size, 90);
|
||||
rank(&mut found, SortKey::Bitrate);
|
||||
assert_eq!(found[0].bitrate, Some(320));
|
||||
rank(&mut found, SortKey::Speed);
|
||||
assert_eq!(found[0].speed, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranking_is_deterministic_for_equivalent_results() {
|
||||
let tie = vec![
|
||||
result("zed", 1, 1, vec![file("x.mp3", 1, Some(128))]),
|
||||
result("amy", 1, 1, vec![file("x.mp3", 1, Some(128))]),
|
||||
];
|
||||
let mut found = candidates(&tie, &Filter::default());
|
||||
rank(&mut found, SortKey::Best);
|
||||
assert_eq!(found[0].user, "amy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_path_uses_the_last_segment_of_a_windows_style_path() {
|
||||
assert_eq!(
|
||||
local_path("/music", "@@abc\\Albums\\01 - Track.mp3"),
|
||||
PathBuf::from("/music/01 - Track.mp3")
|
||||
);
|
||||
assert_eq!(
|
||||
local_path("/music", "plain.mp3"),
|
||||
PathBuf::from("/music/plain.mp3")
|
||||
);
|
||||
assert_eq!(
|
||||
local_path("/music", "unix/style/file.mp3"),
|
||||
PathBuf::from("/music/file.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_accepts_the_json_records_search_emits() {
|
||||
let request = parse_request(
|
||||
r#"{"user":"bob","path":"a\\b.mp3","size":42,"bitrate":320}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(request.user, "bob");
|
||||
assert_eq!(request.path, "a\\b.mp3");
|
||||
assert_eq!(request.size, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_accepts_the_text_records_search_emits() {
|
||||
let request = parse_request("bob\t4096\t320\t@@x\\track.mp3")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(request.user, "bob");
|
||||
assert_eq!(request.size, 4096);
|
||||
assert_eq!(request.path, "@@x\\track.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_accepts_a_bare_user_and_path_pair() {
|
||||
let request = parse_request("bob\t@@x\\track.mp3").unwrap().unwrap();
|
||||
assert_eq!(request.user, "bob");
|
||||
assert_eq!(request.path, "@@x\\track.mp3");
|
||||
assert_eq!(request.size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_skips_blank_lines_and_rejects_junk() {
|
||||
assert_eq!(parse_request(" ").unwrap(), None);
|
||||
assert!(parse_request("nonsense").is_err());
|
||||
assert!(parse_request("{\"path\":\"a\"}").is_err());
|
||||
assert!(parse_request("\tpath-only").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_line_does_not_discard_the_good_ones() {
|
||||
let input = "bob\t10\t320\ta.mp3\nnonsense\n\nsue\tb.mp3\n";
|
||||
let out = Out::new(false, true);
|
||||
let requests = read_requests(&mut input.as_bytes(), &out).unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].user, "sue");
|
||||
}
|
||||
|
||||
fn request(user: &str, path: &str) -> Request {
|
||||
Request {
|
||||
user: user.into(),
|
||||
path: path.into(),
|
||||
size: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_remote_paths_are_dropped_because_the_store_keys_by_path() {
|
||||
let out = Out::new(false, true);
|
||||
let kept = dedupe(
|
||||
vec![
|
||||
request("a", "same.mp3"),
|
||||
request("b", "same.mp3"),
|
||||
request("c", "other.mp3"),
|
||||
],
|
||||
"/music",
|
||||
&out,
|
||||
);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(kept[0].user, "a");
|
||||
assert_eq!(kept[1].path, "other.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_that_would_overwrite_the_same_local_file_are_dropped() {
|
||||
// Different remote paths, same basename: both would be written to
|
||||
// /music/track.mp3 and the slower transfer would clobber the other.
|
||||
let out = Out::new(false, true);
|
||||
let kept = dedupe(
|
||||
vec![
|
||||
request("a", "@@one\\album\\track.mp3"),
|
||||
request("b", "@@two\\other\\track.mp3"),
|
||||
],
|
||||
"/music",
|
||||
&out,
|
||||
);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].user, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_rejects_a_json_record_with_empty_fields() {
|
||||
assert!(parse_request(r#"{"user":"","path":"a.mp3"}"#).is_err());
|
||||
assert!(parse_request(r#"{"user":"bob","path":""}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_ignores_extra_json_fields_a_filter_may_have_added() {
|
||||
let request =
|
||||
parse_request(r#"{"user":"bob","path":"a.mp3","size":7,"tag":1}"#)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(request.size, 7);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user