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:
+162
-417
@@ -1,149 +1,177 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod commands;
|
||||
mod directories;
|
||||
mod models;
|
||||
mod output;
|
||||
mod persist;
|
||||
mod port_mapping;
|
||||
mod ui;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, parse_server_address};
|
||||
use color_eyre::Result;
|
||||
use config::SearchConfig;
|
||||
use soulseek_rs::{Client, ClientSettings, PeerAddress};
|
||||
use std::{
|
||||
env,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{FileSelector, launch_main_tui, show_multi_download_progress};
|
||||
use commands::Ctx;
|
||||
use output::{CliError, CliResult, Exit, Out};
|
||||
use soulseek_rs::{ClientSettings, PeerAddress};
|
||||
use std::io::{BufRead, IsTerminal};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{env, io};
|
||||
use ui::launch_main_tui;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
fn main() -> ExitCode {
|
||||
dotenv::dotenv().ok();
|
||||
color_eyre::install()?;
|
||||
let _ = color_eyre::install();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
init_logging(&cli);
|
||||
|
||||
// Layer CLI/env values over config.toml over built-in defaults.
|
||||
let config_path = persist::paths::config_file();
|
||||
let out = Out::new(cli.json, cli.quiet);
|
||||
match run(cli, &out) {
|
||||
Ok(()) => Exit::Ok.into(),
|
||||
Err(error) => {
|
||||
out.error(&error.message);
|
||||
error.exit.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let config_path = config_path(&cli);
|
||||
let file_config = match &config_path {
|
||||
Some(path) => persist::config::FileConfig::load(path)?,
|
||||
Some(path) => persist::config::FileConfig::load(path)
|
||||
.map_err(|e| CliError::usage(e.to_string()))?,
|
||||
None => persist::config::FileConfig::default(),
|
||||
};
|
||||
let resolved = persist::config::resolve(&cli, &file_config);
|
||||
|
||||
// `portmap` is a local network diagnostic; it needs no server credentials,
|
||||
// so handle it before requiring a username/password.
|
||||
if matches!(cli.command, Some(Commands::Portmap)) {
|
||||
println!(
|
||||
"Testing automatic port mapping for TCP {}…",
|
||||
resolved.listener_port
|
||||
);
|
||||
println!("{}", port_mapping::diagnose(resolved.listener_port));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The interactive TUI handles missing credentials itself with a
|
||||
// login/registration screen; only the one-shot subcommands hard-require
|
||||
// them up front.
|
||||
if cli.command.is_none() {
|
||||
let Some(command) = cli.command.take() else {
|
||||
return run_default_tui(&cli, &resolved, config_path, &file_config);
|
||||
};
|
||||
|
||||
// A local network probe needs no account, so it runs before credentials
|
||||
// are demanded.
|
||||
if matches!(command, Commands::Portmap) {
|
||||
return commands::portmap(out, resolved.listener_port);
|
||||
}
|
||||
|
||||
let ctx = context(&cli, &resolved, out)?;
|
||||
commands::run(&ctx, command)
|
||||
}
|
||||
|
||||
/// Which config file to read: an explicit `--config`, the platform default,
|
||||
/// or none at all under `--no-config`.
|
||||
fn config_path(cli: &Cli) -> Option<PathBuf> {
|
||||
if cli.no_config {
|
||||
return None;
|
||||
}
|
||||
cli.config.clone().or_else(persist::paths::config_file)
|
||||
}
|
||||
|
||||
/// Assemble everything a one-shot command needs, demanding the credentials
|
||||
/// the interactive login screen would otherwise ask for.
|
||||
fn context(
|
||||
cli: &Cli,
|
||||
resolved: &persist::config::Resolved,
|
||||
out: &Out,
|
||||
) -> CliResult<Ctx> {
|
||||
let username = resolved.username.clone().ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml"
|
||||
CliError::usage(
|
||||
"no username: pass --username, set SOULSEEK_USERNAME, or put one \
|
||||
in config.toml",
|
||||
)
|
||||
})?;
|
||||
let password = password(cli, resolved)?.ok_or_else(|| {
|
||||
CliError::usage(
|
||||
"no password: pass --password/--password-stdin, set \
|
||||
SOULSEEK_PASSWORD, store one in the OS keychain, or set \
|
||||
password_cmd",
|
||||
)
|
||||
})?;
|
||||
let (host, port) =
|
||||
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
||||
|
||||
let password = persist::secret::resolve_password(
|
||||
Ok(Ctx {
|
||||
out: out.clone(),
|
||||
settings: ClientSettings {
|
||||
username,
|
||||
password,
|
||||
server_address: PeerAddress::new(host, port),
|
||||
enable_listen: !resolved.disable_listener,
|
||||
listen_port: resolved.listener_port,
|
||||
shared_directories: shared_directories(resolved, out),
|
||||
},
|
||||
download_dir: resolved.download_dir.clone(),
|
||||
max_concurrent_downloads: resolved.max_concurrent_downloads,
|
||||
search_timeout: Duration::from_secs(resolved.search_timeout),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the password: stdin beats the flag/env, which beats the keychain,
|
||||
/// which beats `password_cmd`.
|
||||
fn password(
|
||||
cli: &Cli,
|
||||
resolved: &persist::config::Resolved,
|
||||
) -> CliResult<Option<String>> {
|
||||
if cli.password_stdin {
|
||||
let mut line = String::new();
|
||||
io::stdin()
|
||||
.lock()
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?;
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.is_empty() {
|
||||
return Err(CliError::usage("no password on stdin"));
|
||||
}
|
||||
return Ok(Some(line.to_string()));
|
||||
}
|
||||
Ok(persist::secret::resolve_password(
|
||||
cli.password.as_deref(),
|
||||
resolved.username.as_deref(),
|
||||
resolved.password_cmd.as_deref(),
|
||||
&persist::secret::KeyringStore,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"Password required: use --password, set SOULSEEK_PASSWORD, or store one in the OS keychain"
|
||||
)
|
||||
})?;
|
||||
|
||||
let (server_host, server_port) = parse_server_address(&resolved.server)?;
|
||||
|
||||
// Resolve the configured shared/upload directories up front; a
|
||||
// misconfigured one is a warning, not a fatal error.
|
||||
let shared_directories =
|
||||
directories::resolve_shared_directories(&resolved.shared_dirs);
|
||||
|
||||
let settings = ClientSettings {
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
server_address: PeerAddress::new(server_host.clone(), server_port),
|
||||
enable_listen: !resolved.disable_listener,
|
||||
listen_port: resolved.listener_port,
|
||||
shared_directories: shared_directories.clone(),
|
||||
};
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Search {
|
||||
query,
|
||||
timeout,
|
||||
download_dir,
|
||||
max_concurrent_downloads,
|
||||
}) => {
|
||||
let config = SearchConfig {
|
||||
username,
|
||||
password,
|
||||
server_host,
|
||||
server_port,
|
||||
enable_listener: !resolved.disable_listener,
|
||||
listener_port: resolved.listener_port,
|
||||
query,
|
||||
timeout,
|
||||
download_dir,
|
||||
verbose: cli.verbose,
|
||||
max_concurrent_downloads,
|
||||
shared_directories,
|
||||
};
|
||||
search_and_download(config)
|
||||
}
|
||||
Some(Commands::Message {
|
||||
username: recipient,
|
||||
message,
|
||||
}) => send_private_message(&settings, &recipient, &message),
|
||||
Some(Commands::Browse { username: target }) => {
|
||||
browse_user(&settings, &target)
|
||||
}
|
||||
Some(Commands::Rooms) => list_rooms(&settings),
|
||||
Some(Commands::Chat {
|
||||
room,
|
||||
message,
|
||||
listen_secs,
|
||||
}) => chat_room(&settings, &room, message.as_deref(), listen_secs),
|
||||
// Portmap is handled before the credential check; None returns early
|
||||
// into run_default_tui above.
|
||||
Some(Commands::Portmap) | None => unreachable!(),
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fn init_logging(cli: &Cli) {
|
||||
let log_level = match cli.verbose {
|
||||
0 => "ERROR",
|
||||
1 => "WARN",
|
||||
2 => "INFO",
|
||||
3 => "DEBUG",
|
||||
_ => "TRACE",
|
||||
};
|
||||
// SAFETY: Called before any threads are spawned
|
||||
unsafe { env::set_var("LOG_LEVEL", log_level) };
|
||||
/// Validate the configured shares. A bad entry is a warning, not a failure:
|
||||
/// the client simply shares less.
|
||||
fn shared_directories(
|
||||
resolved: &persist::config::Resolved,
|
||||
out: &Out,
|
||||
) -> Vec<String> {
|
||||
let mut valid = Vec::new();
|
||||
for dir in &resolved.shared_dirs {
|
||||
match directories::resolve_shared_directory(Some(dir)) {
|
||||
Ok(Some(path)) => valid.push(path.display().to_string()),
|
||||
Ok(None) => {}
|
||||
Err(e) => out.warn(&format!("ignoring shared directory: {e}")),
|
||||
}
|
||||
}
|
||||
valid
|
||||
}
|
||||
|
||||
if let Some(log_file) = &cli.log_file {
|
||||
// SAFETY: Called before any threads are spawned
|
||||
unsafe {
|
||||
env::set_var("LOG_FILE", log_file.to_string_lossy().to_string());
|
||||
};
|
||||
/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from
|
||||
/// the environment is honoured, and otherwise logging stays at errors only so
|
||||
/// stderr carries this program's own output and nothing else.
|
||||
fn init_logging(cli: &Cli) {
|
||||
let level = match cli.verbose {
|
||||
0 => (env::var_os("LOG_LEVEL").is_none()
|
||||
&& env::var_os("RUST_LOG").is_none())
|
||||
.then_some("ERROR"),
|
||||
1 => Some("WARN"),
|
||||
2 => Some("INFO"),
|
||||
3 => Some("DEBUG"),
|
||||
_ => Some("TRACE"),
|
||||
};
|
||||
// SAFETY: called before any threads are spawned.
|
||||
unsafe {
|
||||
if let Some(level) = level {
|
||||
env::set_var("LOG_LEVEL", level);
|
||||
}
|
||||
if let Some(log_file) = &cli.log_file {
|
||||
env::set_var("LOG_FILE", log_file.as_os_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,37 +182,41 @@ fn init_logging(cli: &Cli) {
|
||||
fn run_default_tui(
|
||||
cli: &Cli,
|
||||
resolved: &persist::config::Resolved,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
config_path: Option<PathBuf>,
|
||||
file_config: &persist::config::FileConfig,
|
||||
) -> Result<()> {
|
||||
) -> CliResult {
|
||||
use ratatui::crossterm::{
|
||||
event::EnableMouseCapture,
|
||||
execute,
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
let (server_host, server_port) = parse_server_address(&resolved.server)?;
|
||||
// Without a terminal the TUI would panic on its first draw. A script that
|
||||
// reached this point wanted a subcommand, so say that instead of crashing.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand for \
|
||||
scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
|
||||
let out = Out::new(false, false);
|
||||
let (server_host, server_port) =
|
||||
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
||||
|
||||
// Make sure the download folder exists up front: first-run defaults point
|
||||
// download and shared at the same fresh Downloads/Soulseek folder, and
|
||||
// shared-directory validation requires an existing path.
|
||||
if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) {
|
||||
eprintln!(
|
||||
"⚠️ Could not create download directory {}: {e}",
|
||||
out.warn(&format!(
|
||||
"could not create download directory {}: {e}",
|
||||
resolved.download_dir
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
let shared_directories =
|
||||
directories::resolve_shared_directories(&resolved.shared_dirs);
|
||||
|
||||
let shared_directories = shared_directories(resolved, &out);
|
||||
let secret_store = persist::secret::KeyringStore;
|
||||
let initial_password = persist::secret::resolve_password(
|
||||
cli.password.as_deref(),
|
||||
resolved.username.as_deref(),
|
||||
resolved.password_cmd.as_deref(),
|
||||
&secret_store,
|
||||
);
|
||||
let initial_password = password(cli, resolved)?;
|
||||
|
||||
// Enable logger buffering BEFORE connection to prevent log artifacts
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
@@ -229,7 +261,7 @@ fn run_default_tui(
|
||||
Err(e) => {
|
||||
ratatui::restore();
|
||||
soulseek_rs::utils::logger::disable_buffering();
|
||||
return Err(e);
|
||||
return Err(CliError::new(Exit::Failure, e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,6 +278,7 @@ fn run_default_tui(
|
||||
Duration::from_secs(resolved.search_timeout),
|
||||
store,
|
||||
)
|
||||
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
|
||||
}
|
||||
|
||||
/// After a successful login, remember the username in config.toml and — when
|
||||
@@ -253,7 +286,7 @@ fn run_default_tui(
|
||||
/// best-effort: failing to persist must not break a working session.
|
||||
fn persist_credentials(
|
||||
outcome: &ui::login::LoginOutcome,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
config_path: Option<PathBuf>,
|
||||
file_config: &persist::config::FileConfig,
|
||||
secret_store: &dyn persist::secret::SecretStore,
|
||||
) {
|
||||
@@ -272,291 +305,3 @@ fn persist_credentials(
|
||||
eprintln!("⚠️ Could not store password in keychain: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> {
|
||||
use std::time::Instant;
|
||||
|
||||
let _port_mapper = settings
|
||||
.enable_listen
|
||||
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
|
||||
let mut client = Client::with_settings(settings.clone());
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
|
||||
if !client
|
||||
.login()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
|
||||
{
|
||||
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
|
||||
}
|
||||
|
||||
client
|
||||
.browse_user(target)
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to browse: {}", e))?;
|
||||
|
||||
println!("📂 Requesting shared files from {target}...");
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(directories) = client.take_browse_result(target) {
|
||||
if directories.is_empty() {
|
||||
println!("({target} shares nothing)");
|
||||
}
|
||||
for directory in directories {
|
||||
println!("\n{}/", directory.name);
|
||||
for (name, size) in directory.files {
|
||||
println!(" {name} ({size} bytes)");
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(color_eyre::eyre::eyre!(
|
||||
"Timed out waiting for {target}'s file list"
|
||||
))
|
||||
}
|
||||
|
||||
/// Connect and log in, returning the ready client or a descriptive error.
|
||||
fn connect_and_login(settings: &ClientSettings) -> Result<Client> {
|
||||
let mut client = Client::with_settings(settings.clone());
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
|
||||
if !client
|
||||
.login()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
|
||||
{
|
||||
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn list_rooms(settings: &ClientSettings) -> Result<()> {
|
||||
use std::time::Instant;
|
||||
|
||||
let client = connect_and_login(settings)?;
|
||||
client
|
||||
.request_room_list()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to list rooms: {}", e))?;
|
||||
|
||||
println!("📋 Fetching room list...");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut rooms = client.room_list();
|
||||
while rooms.is_empty() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
rooms = client.room_list();
|
||||
}
|
||||
|
||||
rooms.sort_by(|a, b| {
|
||||
b.user_count
|
||||
.cmp(&a.user_count)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
if rooms.is_empty() {
|
||||
println!("(no public rooms reported)");
|
||||
} else {
|
||||
println!("{:>6} room", "users");
|
||||
for room in rooms {
|
||||
println!("{:>6} {}", room.user_count, room.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chat_room(
|
||||
settings: &ClientSettings,
|
||||
room: &str,
|
||||
message: Option<&str>,
|
||||
listen_secs: u64,
|
||||
) -> Result<()> {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
use std::time::Instant;
|
||||
|
||||
let client = connect_and_login(settings)?;
|
||||
client
|
||||
.join_room(room)
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to join room: {}", e))?;
|
||||
|
||||
if let Some(text) = message {
|
||||
client
|
||||
.say_in_room(room, text)
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to say: {}", e))?;
|
||||
// Let the server actor flush before we drop the client.
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
println!("💬 {room}: {text}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"💬 Joined {room}. Listening for {listen_secs}s (Ctrl-C to quit)..."
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(listen_secs);
|
||||
while Instant::now() < deadline {
|
||||
for event in client.take_room_events() {
|
||||
match event {
|
||||
RoomEvent::Message {
|
||||
room: r,
|
||||
username,
|
||||
message,
|
||||
} if r == room => println!("<{username}> {message}"),
|
||||
RoomEvent::UserJoined { room: r, username } if r == room => {
|
||||
println!("→ {username} joined");
|
||||
}
|
||||
RoomEvent::UserLeft { room: r, username } if r == room => {
|
||||
println!("← {username} left");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
let _ = client.leave_room(room);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_private_message(
|
||||
settings: &ClientSettings,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let mut client = Client::with_settings(settings.clone());
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
|
||||
if !client
|
||||
.login()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?
|
||||
{
|
||||
return Err(color_eyre::eyre::eyre!("Login rejected by server"));
|
||||
}
|
||||
|
||||
client
|
||||
.send_private_message(recipient, message)
|
||||
.map_err(|e| {
|
||||
color_eyre::eyre::eyre!("Failed to send message: {}", e)
|
||||
})?;
|
||||
|
||||
// The send is dispatched asynchronously; give the server actor a moment to
|
||||
// flush it to the socket before we drop the client and exit.
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
println!("✉️ Message sent to {recipient}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn search_and_download(config: SearchConfig) -> Result<()> {
|
||||
if config.verbose > 0 {
|
||||
println!(
|
||||
"🔌 Connecting to Soulseek server {}:{}...",
|
||||
config.server_host, config.server_port
|
||||
);
|
||||
}
|
||||
|
||||
let settings = ClientSettings {
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
server_address: PeerAddress::new(
|
||||
config.server_host.clone(),
|
||||
config.server_port,
|
||||
),
|
||||
enable_listen: config.enable_listener,
|
||||
listen_port: config.listener_port,
|
||||
shared_directories: config.shared_directories.clone(),
|
||||
};
|
||||
|
||||
let _port_mapper = settings
|
||||
.enable_listen
|
||||
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
|
||||
|
||||
let mut client = Client::with_settings(settings);
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
|
||||
client
|
||||
.login()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?;
|
||||
|
||||
if config.verbose > 0 {
|
||||
println!("🔍 Searching for: {}", config.query);
|
||||
}
|
||||
|
||||
// Wrap client in Arc for sharing with FileSelector
|
||||
let client = Arc::new(client);
|
||||
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let search_client = client.clone();
|
||||
let search_query = config.query.clone();
|
||||
let search_timeout = Duration::from_secs(config.timeout);
|
||||
let search_cancel = cancel_flag.clone();
|
||||
|
||||
let _search_handle = std::thread::spawn(move || {
|
||||
search_client.search_with_cancel(
|
||||
&search_query,
|
||||
search_timeout,
|
||||
Some(search_cancel),
|
||||
)
|
||||
});
|
||||
|
||||
// Launch FileSelector immediately with live search enabled
|
||||
let terminal = ratatui::init();
|
||||
let mut file_selector = FileSelector::new_with_live_search(
|
||||
client.clone(),
|
||||
config.query.clone(),
|
||||
Duration::from_secs(config.timeout),
|
||||
cancel_flag.clone(),
|
||||
);
|
||||
let (terminal, selected_indices) = file_selector.run(terminal)?;
|
||||
|
||||
// Cancel search thread - no need to wait for it
|
||||
cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let results = client.get_search_results(&config.query);
|
||||
|
||||
if selected_indices.is_empty() {
|
||||
ratatui::restore();
|
||||
println!("❌ No files selected for download");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Directly map selected indices to file data (skip expensive all_files conversion)
|
||||
let selected_files: Vec<_> = selected_indices
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let mut current = 0;
|
||||
for result in &results {
|
||||
let next = current + result.files.len();
|
||||
if idx < next {
|
||||
let file_idx = idx - current;
|
||||
let file = &result.files[file_idx];
|
||||
return Some((
|
||||
file.name.clone(),
|
||||
result.username.clone(),
|
||||
file.size,
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
if selected_files.is_empty() {
|
||||
ratatui::restore();
|
||||
println!("❌ No files found in search results");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Show multi-download progress view immediately (initializes downloads asynchronously)
|
||||
show_multi_download_progress(
|
||||
terminal,
|
||||
client,
|
||||
selected_files,
|
||||
config.download_dir,
|
||||
config.max_concurrent_downloads,
|
||||
)?;
|
||||
|
||||
println!("\n✨ Download complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user