575 lines
22 KiB
Rust
575 lines
22 KiB
Rust
mod app;
|
|
mod events;
|
|
mod file_dialog;
|
|
mod logo;
|
|
mod network;
|
|
mod qbittorrent;
|
|
mod ui;
|
|
|
|
use std::{
|
|
io,
|
|
panic,
|
|
time::Duration,
|
|
};
|
|
|
|
use crossterm::{
|
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
use tokio::sync::mpsc;
|
|
use tui_input::backend::crossterm::EventHandler;
|
|
use simplelog::{Config, LevelFilter, WriteLogger};
|
|
|
|
use crate::app::App;
|
|
use crate::events::{Command, NetworkEvent};
|
|
use crate::network::spawn_network_worker;
|
|
use crate::ui::draw;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
setup_panic_hook();
|
|
setup_logging();
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let base_url = std::env::var("QBIT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
|
let username = std::env::var("QBIT_USER").unwrap_or_else(|_| "admin".to_string());
|
|
let password = std::env::var("QBIT_PASS").unwrap_or_else(|_| "adminadmin".to_string());
|
|
|
|
let mut app = App::new(base_url.clone(), username.clone(), password.clone());
|
|
|
|
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(100);
|
|
let (net_tx, mut net_rx) = mpsc::channel::<NetworkEvent>(100);
|
|
|
|
// The worker is spawned immediately but waits for an Authenticate command.
|
|
// We pass the credentials from env so the user sees them pre-filled.
|
|
spawn_network_worker(base_url, username, password, cmd_rx, net_tx);
|
|
|
|
let (key_tx, mut key_rx) = mpsc::channel::<Event>(100);
|
|
tokio::task::spawn_blocking(move || {
|
|
loop {
|
|
if let Ok(evt) = event::read() {
|
|
if key_tx.blocking_send(evt).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut ticker = tokio::time::interval(Duration::from_millis(16));
|
|
|
|
let result = run_app(
|
|
&mut terminal,
|
|
&mut app,
|
|
&mut key_rx,
|
|
&mut net_rx,
|
|
&cmd_tx,
|
|
&mut ticker,
|
|
)
|
|
.await;
|
|
|
|
let _ = cmd_tx.send(Command::Shutdown).await;
|
|
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
if let Err(err) = result {
|
|
eprintln!("Application error: {:?}", err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn setup_logging() {
|
|
let log_dir = std::env::var("HOME")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
|
.join(".local/share/qbit-tui");
|
|
let _ = std::fs::create_dir_all(&log_dir);
|
|
let log_file = log_dir.join("qbit-tui.log");
|
|
let file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&log_file)
|
|
.unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
|
|
let _ = WriteLogger::init(LevelFilter::Info, Config::default(), file);
|
|
}
|
|
|
|
fn notify_completed(name: &str) {
|
|
let _ = notify_rust::Notification::new()
|
|
.summary("qbit-tui")
|
|
.body(&format!("Download completed: {}", name))
|
|
.timeout(notify_rust::Timeout::Milliseconds(5000))
|
|
.show();
|
|
}
|
|
|
|
fn setup_panic_hook() {
|
|
let original_hook = panic::take_hook();
|
|
panic::set_hook(Box::new(move |info| {
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
|
original_hook(info);
|
|
}));
|
|
}
|
|
|
|
async fn run_app(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
app: &mut App,
|
|
key_rx: &mut mpsc::Receiver<Event>,
|
|
net_rx: &mut mpsc::Receiver<NetworkEvent>,
|
|
cmd_tx: &mpsc::Sender<Command>,
|
|
ticker: &mut tokio::time::Interval,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
while app.running {
|
|
tokio::select! {
|
|
_ = ticker.tick() => {
|
|
draw_and_cursor(terminal, app)?;
|
|
}
|
|
|
|
Some(evt) = key_rx.recv() => {
|
|
handle_input(evt, app, cmd_tx).await?;
|
|
draw_and_cursor(terminal, app)?;
|
|
}
|
|
|
|
Some(net_evt) = net_rx.recv() => {
|
|
// Detect completed downloads before updating state
|
|
if let NetworkEvent::TorrentList(ref list) = net_evt {
|
|
let old_status: std::collections::HashMap<&str, &str> = app.torrents.iter().map(|t| (t.hash.as_str(), t.status.as_str())).collect();
|
|
for t in list {
|
|
let was_downloading = old_status.get(t.hash.as_str()).map(|s| {
|
|
*s == "downloading" || *s == "stalledDL" || *s == "metaDL" || *s == "forcedDL" || *s == "checkingDL"
|
|
}).unwrap_or(false);
|
|
let is_completed = t.status == "uploading" || t.status == "stalledUP" || t.status == "pausedUP" || t.status == "stoppedUP" || t.status == "forcedUP";
|
|
if was_downloading && is_completed {
|
|
notify_completed(&t.name);
|
|
}
|
|
}
|
|
}
|
|
app.handle_network_event(net_evt);
|
|
// Switch from login to main on successful auth
|
|
if app.screen == app::Screen::Login && app.authenticated {
|
|
app.screen = app::Screen::Main;
|
|
}
|
|
draw_and_cursor(terminal, app)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Render the frame and position the physical cursor when the add-modal is active.
|
|
fn draw_and_cursor(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
app: &mut App,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut cursor_pos: Option<(u16, u16)> = None;
|
|
terminal.draw(|f| draw(f, app, &mut cursor_pos))?;
|
|
if let Some((x, y)) = cursor_pos {
|
|
terminal.set_cursor(x, y)?;
|
|
terminal.show_cursor()?;
|
|
} else {
|
|
terminal.hide_cursor()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Send tracker/file/property fetch commands when the details panel is open.
|
|
async fn refresh_details(app: &mut App, cmd_tx: &mpsc::Sender<Command>) {
|
|
if !app.show_details {
|
|
return;
|
|
}
|
|
if let Some(hash) = app.selected_torrent_hash() {
|
|
match app.details_tab {
|
|
app::DetailsTab::General => {
|
|
let _ = cmd_tx.send(Command::FetchProperties(hash)).await;
|
|
}
|
|
app::DetailsTab::Trackers => {
|
|
let _ = cmd_tx.send(Command::FetchTrackers(hash)).await;
|
|
}
|
|
app::DetailsTab::Files => {
|
|
let _ = cmd_tx.send(Command::FetchFiles(hash)).await;
|
|
}
|
|
app::DetailsTab::Peers => {
|
|
let _ = cmd_tx.send(Command::FetchPeers(hash)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_input(
|
|
evt: Event,
|
|
app: &mut App,
|
|
cmd_tx: &mpsc::Sender<Command>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
if let Event::Key(key) = evt {
|
|
if key.kind != KeyEventKind::Press {
|
|
return Ok(());
|
|
}
|
|
|
|
// ── Login Screen ───────────────────────────────────────────────
|
|
if app.screen == app::Screen::Login {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.quit(),
|
|
KeyCode::Tab => {
|
|
app.login_focus = app.login_focus.next();
|
|
}
|
|
KeyCode::BackTab => {
|
|
app.login_focus = app.login_focus.prev();
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.login_focus == app::LoginFocus::Button {
|
|
let url = app.login_url_input.value().to_string();
|
|
let user = app.login_user_input.value().to_string();
|
|
let pass = app.login_pass_input.value().to_string();
|
|
app.base_url = url.clone();
|
|
app.username = user.clone();
|
|
app.password = pass.clone();
|
|
app.status_message = Some("Authenticating...".to_string());
|
|
let _ = cmd_tx
|
|
.send(Command::Authenticate {
|
|
username: user,
|
|
password: pass,
|
|
})
|
|
.await;
|
|
} else {
|
|
app.login_focus = app.login_focus.next();
|
|
}
|
|
}
|
|
KeyCode::Up => {
|
|
app.login_focus = app.login_focus.prev();
|
|
}
|
|
KeyCode::Down => {
|
|
app.login_focus = app.login_focus.next();
|
|
}
|
|
_ => {
|
|
match app.login_focus {
|
|
app::LoginFocus::Url => {
|
|
app.login_url_input.handle_event(&evt);
|
|
}
|
|
app::LoginFocus::Username => {
|
|
app.login_user_input.handle_event(&evt);
|
|
}
|
|
app::LoginFocus::Password => {
|
|
app.login_pass_input.handle_event(&evt);
|
|
}
|
|
app::LoginFocus::Button => {}
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// ── Search Mode ────────────────────────────────────────────────
|
|
if app.show_search {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.show_search = false;
|
|
app.search_input.reset();
|
|
app.needs_filter_recalc = true;
|
|
}
|
|
KeyCode::Enter => {
|
|
app.show_search = false;
|
|
}
|
|
_ => {
|
|
app.search_input.handle_event(&evt);
|
|
app.needs_filter_recalc = true;
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// ── Add Torrent Advanced Modal ─────────────────────────────────
|
|
if app.show_add_modal {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.show_add_modal = false;
|
|
app.add_input.reset();
|
|
app.add_path_input.reset();
|
|
app.add_category_input.reset();
|
|
app.add_tags_input.reset();
|
|
app.add_paused = false;
|
|
app.add_skip_checking = false;
|
|
}
|
|
KeyCode::Enter => {
|
|
let urls = app.add_input.value().to_string();
|
|
if !urls.is_empty() {
|
|
let tags = app.add_tags_input.value().to_string();
|
|
let tags_vec = if tags.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
tags.split(',').map(|t| t.trim().to_string()).collect()
|
|
};
|
|
let _ = cmd_tx.send(Command::AddTorrentAdvanced {
|
|
urls,
|
|
save_path: app.add_path_input.value().to_string(),
|
|
category: app.add_category_input.value().to_string(),
|
|
tags: tags_vec,
|
|
paused: app.add_paused,
|
|
skip_checking: app.add_skip_checking,
|
|
}).await;
|
|
}
|
|
app.show_add_modal = false;
|
|
app.add_input.reset();
|
|
app.add_path_input.reset();
|
|
app.add_category_input.reset();
|
|
app.add_tags_input.reset();
|
|
app.add_paused = false;
|
|
app.add_skip_checking = false;
|
|
}
|
|
KeyCode::Tab => {
|
|
app.add_modal_focus = app.add_modal_focus.next();
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
match app.add_modal_focus {
|
|
app::AddModalFocus::Url => {}
|
|
app::AddModalFocus::Path => {}
|
|
app::AddModalFocus::Category => {}
|
|
app::AddModalFocus::Tags => {}
|
|
}
|
|
// Space toggles advanced options regardless of focus
|
|
app.add_paused = !app.add_paused;
|
|
app.status_message = Some(format!("Add paused: {}", if app.add_paused { "Yes" } else { "No" }));
|
|
}
|
|
_ => {
|
|
match app.add_modal_focus {
|
|
app::AddModalFocus::Url => {
|
|
app.add_input.handle_event(&evt);
|
|
}
|
|
app::AddModalFocus::Path => {
|
|
app.add_path_input.handle_event(&evt);
|
|
}
|
|
app::AddModalFocus::Category => {
|
|
app.add_category_input.handle_event(&evt);
|
|
}
|
|
app::AddModalFocus::Tags => {
|
|
app.add_tags_input.handle_event(&evt);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// ── Delete Confirmation Modal ──────────────────────────────────
|
|
if app.show_delete_confirm {
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
let hashes = if !app.selected_hashes.is_empty() {
|
|
app.selected_hashes.iter().cloned().collect()
|
|
} else if let Some(hash) = app.selected_torrent_hash() {
|
|
vec![hash]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
if !hashes.is_empty() {
|
|
let _ = cmd_tx
|
|
.send(Command::Delete {
|
|
hashes,
|
|
delete_files: app.delete_with_files,
|
|
})
|
|
.await;
|
|
}
|
|
app.show_delete_confirm = false;
|
|
app.clear_selection();
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
app.show_delete_confirm = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// ── Normal Mode ────────────────────────────────────────────────
|
|
match key.code {
|
|
// Quit
|
|
KeyCode::Char('q') => app.quit(),
|
|
KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
|
|
app.quit()
|
|
}
|
|
|
|
// Focus cycling
|
|
KeyCode::Tab => app.next_focus(),
|
|
KeyCode::BackTab => app.prev_focus(),
|
|
KeyCode::Char('h') | KeyCode::Left => app.prev_focus(),
|
|
KeyCode::Char('l') | KeyCode::Right => app.next_focus(),
|
|
|
|
// Details panel toggle
|
|
KeyCode::Char('i') => {
|
|
app.toggle_details();
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
|
|
// Details tabs
|
|
KeyCode::Char('1') => {
|
|
app.details_tab = app::DetailsTab::General;
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
KeyCode::Char('2') => {
|
|
app.details_tab = app::DetailsTab::Trackers;
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
KeyCode::Char('3') => {
|
|
app.details_tab = app::DetailsTab::Files;
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
KeyCode::Char('4') => {
|
|
app.details_tab = app::DetailsTab::Peers;
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
KeyCode::Char(']') => {
|
|
app.next_tab();
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
KeyCode::Char('[') => {
|
|
app.previous_tab();
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
|
|
// Search toggle
|
|
KeyCode::Char('/') => {
|
|
app.show_search = true;
|
|
}
|
|
|
|
// Sorting
|
|
KeyCode::Char('.') => {
|
|
app.next_sort_column();
|
|
app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" }));
|
|
}
|
|
KeyCode::Char(',') => {
|
|
app.toggle_sort_direction();
|
|
app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" }));
|
|
}
|
|
|
|
// Multi-selection
|
|
KeyCode::Char('s') => {
|
|
app.toggle_select_current();
|
|
}
|
|
KeyCode::Char('S') => {
|
|
app.clear_selection();
|
|
}
|
|
|
|
// Add torrent file via native file dialog
|
|
KeyCode::Char('a') => {
|
|
let paths = file_dialog::pick_torrent_files();
|
|
for path in paths {
|
|
let _ = cmd_tx.send(Command::AddFile(path)).await;
|
|
}
|
|
}
|
|
|
|
// Add magnet modal
|
|
KeyCode::Char('m') => {
|
|
app.show_add_modal = true;
|
|
}
|
|
|
|
// Toggle speed limits
|
|
KeyCode::Char('L') => {
|
|
let _ = cmd_tx.send(Command::ToggleSpeedLimits).await;
|
|
}
|
|
|
|
// Recheck selected / current torrents
|
|
KeyCode::Char('r') => {
|
|
let hashes = if !app.selected_hashes.is_empty() {
|
|
app.selected_hashes.iter().cloned().collect()
|
|
} else if let Some(t) = app.selected_torrent() {
|
|
vec![t.hash.clone()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
if !hashes.is_empty() {
|
|
let _ = cmd_tx.send(Command::Recheck(hashes)).await;
|
|
}
|
|
}
|
|
|
|
// Navigation depends on current focus
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
match app.focus {
|
|
app::Focus::Sidebar => app.previous_sidebar(),
|
|
app::Focus::TorrentList => {
|
|
app.previous_torrent();
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
app::Focus::DetailsPanel => {}
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
match app.focus {
|
|
app::Focus::Sidebar => app.next_sidebar(),
|
|
app::Focus::TorrentList => {
|
|
app.next_torrent();
|
|
refresh_details(app, cmd_tx).await;
|
|
}
|
|
app::Focus::DetailsPanel => {}
|
|
}
|
|
}
|
|
|
|
// Pause / Resume (supports multi-selection)
|
|
KeyCode::Char('p') => {
|
|
if !app.selected_hashes.is_empty() {
|
|
let hashes: Vec<String> = app.selected_hashes.iter().cloned().collect();
|
|
// Check majority state of selected torrents
|
|
let paused_count = app.torrents.iter()
|
|
.filter(|t| app.selected_hashes.contains(&t.hash))
|
|
.filter(|t| t.status == "pausedDL" || t.status == "pausedUP" || t.status == "stoppedDL" || t.status == "stoppedUP")
|
|
.count();
|
|
let is_majority_paused = paused_count * 2 >= hashes.len();
|
|
let (cmd, msg) = if is_majority_paused {
|
|
(Command::Resume(hashes.clone()), format!("Resuming {} torrent(s)...", hashes.len()))
|
|
} else {
|
|
(Command::Pause(hashes.clone()), format!("Pausing {} torrent(s)...", hashes.len()))
|
|
};
|
|
app.status_message = Some(msg);
|
|
let _ = cmd_tx.send(cmd).await;
|
|
} else if let Some(torrent) = app.selected_torrent() {
|
|
let hash = torrent.hash.clone();
|
|
let is_paused = torrent.status == "pausedDL"
|
|
|| torrent.status == "pausedUP"
|
|
|| torrent.status == "stoppedDL"
|
|
|| torrent.status == "stoppedUP";
|
|
let (cmd, msg) = if is_paused {
|
|
(Command::Resume(vec![hash.clone()]), format!("Resuming {}...", &hash[..8.min(hash.len())]))
|
|
} else {
|
|
(Command::Pause(vec![hash.clone()]), format!("Pausing {}...", &hash[..8.min(hash.len())]))
|
|
};
|
|
app.status_message = Some(msg);
|
|
let _ = cmd_tx.send(cmd).await;
|
|
}
|
|
}
|
|
|
|
// Delete (supports multi-selection)
|
|
KeyCode::Char('x') => {
|
|
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
|
app.show_delete_confirm = true;
|
|
app.delete_with_files = false;
|
|
}
|
|
}
|
|
KeyCode::Delete => {
|
|
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
|
app.show_delete_confirm = true;
|
|
app.delete_with_files = false;
|
|
}
|
|
}
|
|
KeyCode::Char('X') => {
|
|
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
|
app.show_delete_confirm = true;
|
|
app.delete_with_files = true;
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|