qbit-tui/src/main.rs

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(())
}