Add advanced features: search, sort, multi-select, missing QB actions, advanced add modal, notifications, file logging, and JSON parsing tests

This commit is contained in:
ewen 2026-05-11 00:23:40 +02:00
parent 1ef01a1a64
commit b78e876e5a
7 changed files with 847 additions and 50 deletions

View file

@ -44,4 +44,10 @@ tui-input = "0.8"
# Date formatting for details panel # Date formatting for details panel
chrono = "0.4" chrono = "0.4"
# Desktop notifications
notify-rust = "4.11"
# File logging
simplelog = { version = "0.12", features = ["paris"] }

View file

@ -1,9 +1,43 @@
use std::collections::HashSet;
use tui_input::Input; use tui_input::Input;
use crate::events::{ use crate::events::{
Category, NetworkEvent, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo, Category, NetworkEvent, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo,
}; };
/// Which column the torrent list is currently sorted by.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Name,
Size,
Progress,
Status,
Ratio,
DlSpeed,
UlSpeed,
Seeds,
Leechers,
Popularity,
}
impl SortColumn {
pub fn next(self) -> Self {
match self {
SortColumn::Name => SortColumn::Size,
SortColumn::Size => SortColumn::Progress,
SortColumn::Progress => SortColumn::Status,
SortColumn::Status => SortColumn::Ratio,
SortColumn::Ratio => SortColumn::DlSpeed,
SortColumn::DlSpeed => SortColumn::UlSpeed,
SortColumn::UlSpeed => SortColumn::Seeds,
SortColumn::Seeds => SortColumn::Leechers,
SortColumn::Leechers => SortColumn::Popularity,
SortColumn::Popularity => SortColumn::Name,
}
}
}
/// Current high-level screen of the application. /// Current high-level screen of the application.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen { pub enum Screen {
@ -69,6 +103,26 @@ pub enum DetailsTab {
Peers, Peers,
} }
/// Focus within the add torrent advanced modal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddModalFocus {
Url,
Path,
Category,
Tags,
}
impl AddModalFocus {
pub fn next(self) -> Self {
match self {
AddModalFocus::Url => AddModalFocus::Path,
AddModalFocus::Path => AddModalFocus::Category,
AddModalFocus::Category => AddModalFocus::Tags,
AddModalFocus::Tags => AddModalFocus::Url,
}
}
}
impl DetailsTab { impl DetailsTab {
pub fn next(self) -> Self { pub fn next(self) -> Self {
match self { match self {
@ -125,9 +179,26 @@ pub struct App {
pub properties: Option<TorrentProperties>, pub properties: Option<TorrentProperties>,
pub peers: Vec<Peer>, pub peers: Vec<Peer>,
// Search state
pub show_search: bool,
pub search_input: Input,
// Sorting state
pub sort_column: SortColumn,
pub sort_ascending: bool,
// Multi-selection state
pub selected_hashes: HashSet<String>,
// Add modal state // Add modal state
pub show_add_modal: bool, pub show_add_modal: bool,
pub add_modal_focus: AddModalFocus,
pub add_input: Input, pub add_input: Input,
pub add_path_input: Input,
pub add_category_input: Input,
pub add_tags_input: Input,
pub add_paused: bool,
pub add_skip_checking: bool,
// Delete modal state // Delete modal state
pub show_delete_confirm: bool, pub show_delete_confirm: bool,
@ -180,8 +251,22 @@ impl App {
properties: None, properties: None,
peers: Vec::new(), peers: Vec::new(),
show_search: false,
search_input: Input::default(),
sort_column: SortColumn::Name,
sort_ascending: true,
selected_hashes: HashSet::new(),
show_add_modal: false, show_add_modal: false,
add_modal_focus: AddModalFocus::Url,
add_input: Input::default(), add_input: Input::default(),
add_path_input: Input::default(),
add_category_input: Input::default(),
add_tags_input: Input::default(),
add_paused: false,
add_skip_checking: false,
show_delete_confirm: false, show_delete_confirm: false,
delete_with_files: false, delete_with_files: false,
@ -212,10 +297,13 @@ impl App {
self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1)); self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1));
} }
/// Return the torrents visible after applying the current sidebar filter. /// Return the torrents visible after applying sidebar filter, search and sort.
pub fn filtered_torrents(&self) -> Vec<&Torrent> { pub fn filtered_torrents(&self) -> Vec<&Torrent> {
let filter = self.sidebar_items.get(self.sidebar_selected); let filter = self.sidebar_items.get(self.sidebar_selected);
self.torrents let search = self.search_input.value().to_lowercase();
let mut filtered: Vec<&Torrent> = self
.torrents
.iter() .iter()
.filter(|t| match filter { .filter(|t| match filter {
Some(SidebarFilter::All) => true, Some(SidebarFilter::All) => true,
@ -241,7 +329,32 @@ impl App {
Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag), Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag),
None => true, None => true,
}) })
.collect() .filter(|t| {
if search.is_empty() {
true
} else {
t.name.to_lowercase().contains(&search)
}
})
.collect();
filtered.sort_by(|a, b| {
let ord = match self.sort_column {
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortColumn::Size => a.size.cmp(&b.size),
SortColumn::Progress => a.progress.partial_cmp(&b.progress).unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Status => a.status.cmp(&b.status),
SortColumn::Ratio => a.ratio.partial_cmp(&b.ratio).unwrap_or(std::cmp::Ordering::Equal),
SortColumn::DlSpeed => a.dl_speed.cmp(&b.dl_speed),
SortColumn::UlSpeed => a.ul_speed.cmp(&b.ul_speed),
SortColumn::Seeds => a.num_seeds.cmp(&b.num_seeds),
SortColumn::Leechers => a.num_leechs.cmp(&b.num_leechs),
SortColumn::Popularity => a.popularity.partial_cmp(&b.popularity).unwrap_or(std::cmp::Ordering::Equal),
};
if self.sort_ascending { ord } else { ord.reverse() }
});
filtered
} }
/// Get the hash of the torrent currently selected in the *filtered* list. /// Get the hash of the torrent currently selected in the *filtered* list.
@ -386,6 +499,28 @@ impl App {
} }
} }
pub fn toggle_select_current(&mut self) {
if let Some(hash) = self.selected_torrent_hash() {
if self.selected_hashes.contains(&hash) {
self.selected_hashes.remove(&hash);
} else {
self.selected_hashes.insert(hash);
}
}
}
pub fn clear_selection(&mut self) {
self.selected_hashes.clear();
}
pub fn next_sort_column(&mut self) {
self.sort_column = self.sort_column.next();
}
pub fn toggle_sort_direction(&mut self) {
self.sort_ascending = !self.sort_ascending;
}
pub fn quit(&mut self) { pub fn quit(&mut self) {
self.running = false; self.running = false;
} }

View file

@ -44,6 +44,15 @@ pub enum Command {
AddMagnet(String), AddMagnet(String),
/// Add a torrent from a local .torrent file path /// Add a torrent from a local .torrent file path
AddFile(String), AddFile(String),
/// Add torrent(s) with advanced options
AddTorrentAdvanced {
urls: String,
save_path: String,
category: String,
tags: Vec<String>,
paused: bool,
skip_checking: bool,
},
/// Toggle global speed limits mode /// Toggle global speed limits mode
ToggleSpeedLimits, ToggleSpeedLimits,
/// Fetch trackers for a given hash /// Fetch trackers for a given hash
@ -54,6 +63,26 @@ pub enum Command {
FetchProperties(String), FetchProperties(String),
/// Fetch peers for a given hash /// Fetch peers for a given hash
FetchPeers(String), FetchPeers(String),
/// Recheck torrents by hash
Recheck(Vec<String>),
/// Relocate torrents to a new path
Relocate { hashes: Vec<String>, new_path: String },
/// Set upload/download limits for torrents
SetTorrentLimits { hashes: Vec<String>, ul_limit: i64, dl_limit: i64 },
/// Set category for torrents
SetCategory { hashes: Vec<String>, category: String },
/// Create a new category
CreateCategory { name: String, save_path: String },
/// Delete a category
DeleteCategory(String),
/// Add tags to torrents
AddTags { hashes: Vec<String>, tags: Vec<String> },
/// Create a new tag
CreateTag(String),
/// Delete a tag
DeleteTag(String),
/// Set file priority
SetFilePriority { hash: String, file_ids: Vec<i64>, priority: i64 },
/// Shutdown the network worker /// Shutdown the network worker
Shutdown, Shutdown,
} }
@ -307,3 +336,82 @@ pub struct Peer {
#[serde(default, deserialize_with = "deserialize_string_or_null")] #[serde(default, deserialize_with = "deserialize_string_or_null")]
pub files: String, pub files: String,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_torrent_defaults() {
let json = r#"{"hash":"abc","name":"test"}"#;
let t: Torrent = serde_json::from_str(json).unwrap();
assert_eq!(t.hash, "abc");
assert_eq!(t.name, "test");
assert_eq!(t.size, 0);
assert_eq!(t.progress, 0.0);
assert_eq!(t.ratio, 0.0);
assert_eq!(t.num_seeds, 0);
assert_eq!(t.num_leechs, 0);
assert_eq!(t.popularity, 0.0);
}
#[test]
fn test_torrent_null_fields() {
let json = r#"{"hash":"abc","name":"test","category":null,"tags":null,"comment":null}"#;
let t: Torrent = serde_json::from_str(json).unwrap();
assert_eq!(t.category, "");
assert!(t.tags.is_empty());
assert_eq!(t.comment, "");
}
#[test]
fn test_torrent_tags_array() {
let json = r#"{"hash":"abc","name":"test","tags":["hd","movies"]}"#;
let t: Torrent = serde_json::from_str(json).unwrap();
assert_eq!(t.tags, vec!["hd", "movies"]);
}
#[test]
fn test_torrent_tags_string() {
let json = r#"{"hash":"abc","name":"test","tags":"hd,movies"}"#;
let t: Torrent = serde_json::from_str(json).unwrap();
assert_eq!(t.tags, vec!["hd", "movies"]);
}
#[test]
fn test_tracker_defaults() {
let json = r#"{"url":"http://tracker"}"#;
let tr: Tracker = serde_json::from_str(json).unwrap();
assert_eq!(tr.url, "http://tracker");
assert_eq!(tr.status, 0);
assert_eq!(tr.num_peers, 0);
}
#[test]
fn test_torrent_file_defaults() {
let json = r#"{"name":"file.mkv"}"#;
let f: TorrentFile = serde_json::from_str(json).unwrap();
assert_eq!(f.name, "file.mkv");
assert_eq!(f.size, 0);
assert_eq!(f.progress, 0.0);
}
#[test]
fn test_properties_defaults() {
let json = r#"{"hash":"abc","name":"test"}"#;
let p: TorrentProperties = serde_json::from_str(json).unwrap();
assert_eq!(p.hash, "abc");
assert_eq!(p.popularity, 0.0);
assert_eq!(p.num_seeds, 0);
assert_eq!(p.num_leechs, 0);
}
#[test]
fn test_peer_defaults() {
let json = r#"{"ip":"1.2.3.4"}"#;
let peer: Peer = serde_json::from_str(json).unwrap();
assert_eq!(peer.ip, "1.2.3.4");
assert_eq!(peer.port, 0);
assert_eq!(peer.client, "");
}
}

View file

@ -20,6 +20,7 @@ use crossterm::{
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tui_input::backend::crossterm::EventHandler; use tui_input::backend::crossterm::EventHandler;
use simplelog::{Config, LevelFilter, WriteLogger};
use crate::app::App; use crate::app::App;
use crate::events::{Command, NetworkEvent}; use crate::events::{Command, NetworkEvent};
@ -29,6 +30,7 @@ use crate::ui::draw;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
setup_panic_hook(); setup_panic_hook();
setup_logging();
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@ -89,6 +91,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) 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() { fn setup_panic_hook() {
let original_hook = panic::take_hook(); let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| { panic::set_hook(Box::new(move |info| {
@ -118,6 +143,19 @@ async fn run_app(
} }
Some(net_evt) = net_rx.recv() => { 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); app.handle_network_event(net_evt);
// Switch from login to main on successful auth // Switch from login to main on successful auth
if app.screen == app::Screen::Login && app.authenticated { if app.screen == app::Screen::Login && app.authenticated {
@ -133,7 +171,7 @@ async fn run_app(
/// Render the frame and position the physical cursor when the add-modal is active. /// Render the frame and position the physical cursor when the add-modal is active.
fn draw_and_cursor( fn draw_and_cursor(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &App, app: &mut App,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut cursor_pos: Option<(u16, u16)> = None; let mut cursor_pos: Option<(u16, u16)> = None;
terminal.draw(|f| draw(f, app, &mut cursor_pos))?; terminal.draw(|f| draw(f, app, &mut cursor_pos))?;
@ -232,24 +270,88 @@ async fn handle_input(
return Ok(()); return Ok(());
} }
// ── Add Magnet Modal ─────────────────────────────────────────── // ── Search Mode ────────────────────────────────────────────────
if app.show_search {
match key.code {
KeyCode::Esc => {
app.show_search = false;
app.search_input.reset();
}
KeyCode::Enter => {
app.show_search = false;
}
_ => {
app.search_input.handle_event(&evt);
}
}
return Ok(());
}
// ── Add Torrent Advanced Modal ─────────────────────────────────
if app.show_add_modal { if app.show_add_modal {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.show_add_modal = false; app.show_add_modal = false;
app.add_input.reset(); 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 => { KeyCode::Enter => {
let urls = app.add_input.value().to_string(); let urls = app.add_input.value().to_string();
if !urls.is_empty() { if !urls.is_empty() {
let _ = cmd_tx.send(Command::AddMagnet(urls)).await; 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.show_add_modal = false;
app.add_input.reset(); 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 => {}
}
} }
_ => { _ => {
match app.add_modal_focus {
app::AddModalFocus::Url => {
app.add_input.handle_event(&evt); 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(()); return Ok(());
} }
@ -258,15 +360,23 @@ async fn handle_input(
if app.show_delete_confirm { if app.show_delete_confirm {
match key.code { match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => { KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(hash) = app.selected_torrent_hash() { 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 let _ = cmd_tx
.send(Command::Delete { .send(Command::Delete {
hashes: vec![hash], hashes,
delete_files: app.delete_with_files, delete_files: app.delete_with_files,
}) })
.await; .await;
} }
app.show_delete_confirm = false; app.show_delete_confirm = false;
app.clear_selection();
} }
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.show_delete_confirm = false; app.show_delete_confirm = false;
@ -322,6 +432,29 @@ async fn handle_input(
refresh_details(app, cmd_tx).await; 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 // Add torrent file via native file dialog
KeyCode::Char('a') => { KeyCode::Char('a') => {
let paths = file_dialog::pick_torrent_files(); let paths = file_dialog::pick_torrent_files();
@ -340,6 +473,20 @@ async fn handle_input(
let _ = cmd_tx.send(Command::ToggleSpeedLimits).await; 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 // Navigation depends on current focus
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
match app.focus { match app.focus {
@ -362,14 +509,14 @@ async fn handle_input(
} }
} }
// Pause / Resume // Pause / Resume (supports multi-selection)
KeyCode::Char('p') => { KeyCode::Char('p') => {
if let Some(torrent) = app.selected_torrent() { if !app.selected_hashes.is_empty() {
let hashes: Vec<String> = app.selected_hashes.iter().cloned().collect();
app.status_message = Some(format!("Pausing {} torrent(s)...", hashes.len()));
let _ = cmd_tx.send(Command::Pause(hashes)).await;
} else if let Some(torrent) = app.selected_torrent() {
let hash = torrent.hash.clone(); let hash = torrent.hash.clone();
if hash.is_empty() {
app.status_message = Some("Error: selected torrent has empty hash".to_string());
return Ok(());
}
let is_paused = torrent.status == "pausedDL" let is_paused = torrent.status == "pausedDL"
|| torrent.status == "pausedUP" || torrent.status == "pausedUP"
|| torrent.status == "stoppedDL" || torrent.status == "stoppedDL"
@ -384,21 +531,21 @@ async fn handle_input(
} }
} }
// Delete // Delete (supports multi-selection)
KeyCode::Char('x') => { KeyCode::Char('x') => {
if app.selected_torrent_hash().is_some() { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
app.show_delete_confirm = true; app.show_delete_confirm = true;
app.delete_with_files = false; app.delete_with_files = false;
} }
} }
KeyCode::Delete => { KeyCode::Delete => {
if app.selected_torrent_hash().is_some() { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
app.show_delete_confirm = true; app.show_delete_confirm = true;
app.delete_with_files = false; app.delete_with_files = false;
} }
} }
KeyCode::Char('X') => { KeyCode::Char('X') => {
if app.selected_torrent_hash().is_some() { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
app.show_delete_confirm = true; app.show_delete_confirm = true;
app.delete_with_files = true; app.delete_with_files = true;
} }

View file

@ -153,6 +153,16 @@ pub fn spawn_network_worker(
} }
} }
} }
Command::AddTorrentAdvanced { urls, save_path, category, tags, paused, skip_checking } => {
match client.add_torrents_advanced(&urls, &save_path, &category, &tags, paused, skip_checking).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) added with options".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::AddFile(path) => { Command::AddFile(path) => {
match client.add_torrent_file(&path).await { match client.add_torrent_file(&path).await {
Ok(()) => { Ok(()) => {
@ -216,6 +226,106 @@ pub fn spawn_network_worker(
} }
} }
} }
Command::Recheck(hashes) => {
match client.recheck_torrents(&hashes).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) rechecked".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::Relocate { hashes, new_path } => {
match client.relocate_torrents(&hashes, &new_path).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) relocated".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::SetTorrentLimits { hashes, ul_limit, dl_limit } => {
match client.set_torrent_limits(&hashes, dl_limit, ul_limit).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Limits updated".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::SetCategory { hashes, category } => {
match client.set_category(&hashes, &category).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category set".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::CreateCategory { name, save_path } => {
match client.create_category(&name, &save_path).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category created".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::DeleteCategory(name) => {
match client.delete_categories(&[name]).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category deleted".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::AddTags { hashes, tags } => {
match client.add_tags(&hashes, &tags).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tags added".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::CreateTag(tag) => {
match client.create_tags(&[tag]).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tag created".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::DeleteTag(tag) => {
match client.delete_tags(&[tag]).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tag deleted".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::SetFilePriority { hash, file_ids, priority } => {
match client.set_file_priority(&hash, &file_ids, priority).await {
Ok(()) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("File priority updated".to_string()))).await;
}
Err(e) => {
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
}
}
}
Command::Shutdown => break, Command::Shutdown => break,
} }
} }

View file

@ -243,6 +243,46 @@ impl QbittorrentClient {
Ok(()) Ok(())
} }
/// Add torrent(s) with advanced options.
pub async fn add_torrents_advanced(
&self,
urls: &str,
save_path: &str,
category: &str,
tags: &[String],
paused: bool,
skip_checking: bool,
) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/add", self.base_url);
let mut params = vec![
("urls", urls.to_string()),
];
if !save_path.is_empty() {
params.push(("savepath", save_path.to_string()));
}
if !category.is_empty() {
params.push(("category", category.to_string()));
}
if !tags.is_empty() {
params.push(("tags", tags.join(",")));
}
if paused {
params.push(("paused", "true".to_string()));
}
if skip_checking {
params.push(("skip_checking", "true".to_string()));
}
let resp = self
.client
.post(&url)
.form(&params)
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Add a torrent from a local .torrent file. /// Add a torrent from a local .torrent file.
pub async fn add_torrent_file(&self, file_path: &str) -> Result<()> { pub async fn add_torrent_file(&self, file_path: &str) -> Result<()> {
self.require_auth()?; self.require_auth()?;
@ -358,6 +398,169 @@ impl QbittorrentClient {
Ok(peers) Ok(peers)
} }
/// Recheck torrents by hash.
pub async fn recheck_torrents(&self, hashes: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/recheck", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Relocate torrents to a new path.
pub async fn relocate_torrents(&self, hashes: &[String], new_path: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/setLocation", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("location", new_path.to_string())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Set upload/download limits for torrents (bytes/s, -1 = unlimited).
pub async fn set_torrent_limits(&self, hashes: &[String], dl_limit: i64, ul_limit: i64) -> Result<()> {
self.require_auth()?;
let hashes_str = hashes.join("|");
if dl_limit >= 0 {
let url = format!("{}/api/v2/torrents/setDownloadLimit", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str.clone()), ("limit", dl_limit.to_string())])
.send()
.await?;
resp.error_for_status()?;
}
if ul_limit >= 0 {
let url = format!("{}/api/v2/torrents/setUploadLimit", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("limit", ul_limit.to_string())])
.send()
.await?;
resp.error_for_status()?;
}
Ok(())
}
/// Set category for torrents.
pub async fn set_category(&self, hashes: &[String], category: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/setCategory", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("category", category.to_string())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Create a new category.
pub async fn create_category(&self, name: &str, save_path: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/createCategory", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("category", name), ("savePath", save_path)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Delete categories.
pub async fn delete_categories(&self, names: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/removeCategories", self.base_url);
let categories = names.join("\n");
let resp = self
.client
.post(&url)
.form(&[("categories", categories)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Add tags to torrents.
pub async fn add_tags(&self, hashes: &[String], tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/addTags", self.base_url);
let hashes_str = hashes.join("|");
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Create new tags.
pub async fn create_tags(&self, tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/createTags", self.base_url);
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Delete tags.
pub async fn delete_tags(&self, tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/deleteTags", self.base_url);
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Set file priority for a torrent.
pub async fn set_file_priority(&self, hash: &str, file_ids: &[i64], priority: i64) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/filePrio", self.base_url);
let ids = file_ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",");
let prio = priority.to_string();
let resp = self
.client
.post(&url)
.form(&[("hash", hash), ("id", ids.as_str()), ("priority", prio.as_str())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
fn require_auth(&self) -> Result<()> { fn require_auth(&self) -> Result<()> {
if !self.is_authenticated { if !self.is_authenticated {
anyhow::bail!("Not authenticated") anyhow::bail!("Not authenticated")

132
src/ui.rs
View file

@ -9,7 +9,7 @@ use ratatui::{
Frame, Frame,
}; };
use crate::app::{App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter}; use crate::app::{AddModalFocus, App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter};
use crate::logo; use crate::logo;
fn format_eta(seconds: i64) -> String { fn format_eta(seconds: i64) -> String {
@ -67,7 +67,7 @@ fn focus_border(focus: Focus, current: Focus) -> Style {
/// Render the entire UI into the given frame. /// Render the entire UI into the given frame.
/// If the add-modal is active, `cursor_pos` is filled with the screen coordinates /// If the add-modal is active, `cursor_pos` is filled with the screen coordinates
/// where the text cursor should be drawn. /// where the text cursor should be drawn.
pub fn draw(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) { pub fn draw(f: &mut Frame, app: &mut App, cursor_pos: &mut Option<(u16, u16)>) {
if app.screen == Screen::Login { if app.screen == Screen::Login {
draw_login_screen(f, app, cursor_pos); draw_login_screen(f, app, cursor_pos);
return; return;
@ -134,7 +134,7 @@ fn draw_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
f.render_widget(free, cols[2]); f.render_widget(free, cols[2]);
} }
fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let body_chunks = Layout::default() let body_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
@ -142,17 +142,49 @@ fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
draw_sidebar(f, app, body_chunks[0]); draw_sidebar(f, app, body_chunks[0]);
let right_area = body_chunks[1];
if app.show_search {
let search_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(right_area);
draw_search_bar(f, app, search_chunks[0]);
if app.show_details {
let detail_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(search_chunks[1]);
draw_torrent_list(f, app, detail_chunks[0]);
draw_details_panel(f, app, detail_chunks[1]);
} else {
draw_torrent_list(f, app, search_chunks[1]);
}
} else {
if app.show_details { if app.show_details {
let right_chunks = Layout::default() let right_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(body_chunks[1]); .split(right_area);
draw_torrent_list(f, app, right_chunks[0]); draw_torrent_list(f, app, right_chunks[0]);
draw_details_panel(f, app, right_chunks[1]); draw_details_panel(f, app, right_chunks[1]);
} else { } else {
draw_torrent_list(f, app, body_chunks[1]); draw_torrent_list(f, app, right_area);
} }
} }
}
fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Search ")
.border_style(Style::default().fg(Color::Green));
let inner = block.inner(area);
f.render_widget(block, area);
let text = Paragraph::new(app.search_input.value());
f.render_widget(text, inner);
}
fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
@ -241,10 +273,19 @@ fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
Cell::from(popularity), Cell::from(popularity),
]; ];
let is_selected = app.selected_hashes.contains(&t.hash);
let style = if i == app.torrent_selected { let style = if i == app.torrent_selected {
if is_selected {
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default() Style::default()
.bg(Color::DarkGray) .bg(Color::DarkGray)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
}
} else if is_selected {
Style::default().bg(Color::Blue)
} else { } else {
Style::default() Style::default()
}; };
@ -551,19 +592,28 @@ fn draw_details_peers(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let status = app.status_message.as_deref().unwrap_or("Ready"); let status = app.status_message.as_deref().unwrap_or("Ready");
let (help_top, help_bottom) = if app.show_delete_confirm { let selected_count = app.selected_hashes.len();
("y: Confirm | n: Cancel", "") let selection_hint = if selected_count > 0 {
format!(" [{} selected]", selected_count)
} else {
String::new()
};
let (help_top, help_bottom): (String, String) = if app.show_delete_confirm {
("y: Confirm | n: Cancel".to_string(), "".to_string())
} else if app.show_add_modal { } else if app.show_add_modal {
("Enter: Submit | Esc: Cancel", "") ("Enter: Submit | Esc: Cancel".to_string(), "".to_string())
} else if app.show_search {
("Enter: Close search | Esc: Clear".to_string(), "".to_string())
} else if app.show_details { } else if app.show_details {
( (
"q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet", format!("q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet{}", selection_hint),
"1:Général 2:Trackers 3:Fichiers 4:Peers [:Prev ]:Next", "1:Général 2:Trackers 3:Fichiers 4:Peers s:Select S:ClearSel .:Sort ,:Dir /:Search".to_string(),
) )
} else { } else {
( (
"q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet", format!("q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet{}", selection_hint),
"h/l:Change Focus", "h/l:ChangeFocus s:Select S:ClearSel .:Sort ,:Dir /:Search".to_string(),
) )
}; };
@ -586,24 +636,24 @@ fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
f.render_widget(status_par, rows[0]); f.render_widget(status_par, rows[0]);
if help_bottom.is_empty() { if help_bottom.is_empty() {
let help_par = Paragraph::new(help_top).alignment(Alignment::Left); let help_par = Paragraph::new(help_top.as_str()).alignment(Alignment::Left);
f.render_widget(help_par, rows[1]); f.render_widget(help_par, rows[1]);
} else { } else {
let help_cols = Layout::default() let help_cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[1]); .split(rows[1]);
let help_par = Paragraph::new(help_top).alignment(Alignment::Left); let help_par = Paragraph::new(help_top.as_str()).alignment(Alignment::Left);
f.render_widget(help_par, help_cols[0]); f.render_widget(help_par, help_cols[0]);
let help2_par = Paragraph::new(help_bottom).alignment(Alignment::Right); let help2_par = Paragraph::new(help_bottom.as_str()).alignment(Alignment::Right);
f.render_widget(help2_par, help_cols[1]); f.render_widget(help2_par, help_cols[1]);
} }
} }
fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) { fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) {
let area = centered_rect(60, 20, f.size()); let area = centered_rect(70, 40, f.size());
let block = Block::default() let block = Block::default()
.title(" Add Magnet / URL(s) ") .title(" Add Torrent — URLs / Path / Category / Tags ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)); .border_style(Style::default().fg(Color::Green));
@ -611,14 +661,52 @@ fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>)
f.render_widget(Clear, area); f.render_widget(Clear, area);
f.render_widget(block, area); f.render_widget(block, area);
let text = Paragraph::new(app.add_input.value()).wrap(Wrap { trim: true }); let chunks = Layout::default()
f.render_widget(text, inner); .direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(inner);
// Position for the physical cursor let fields = [
let x = inner.x + app.add_input.visual_cursor() as u16; (AddModalFocus::Url, " URLs ", app.add_input.value()),
let y = inner.y; (AddModalFocus::Path, " Save Path ", app.add_path_input.value()),
(AddModalFocus::Category, " Category ", app.add_category_input.value()),
(AddModalFocus::Tags, " Tags (comma separated) ", app.add_tags_input.value()),
];
for (i, (focus, label, value)) in fields.iter().enumerate() {
let focused = app.add_modal_focus == *focus;
let border_style = if focused {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let input_block = Block::default()
.title(*label)
.borders(Borders::ALL)
.border_style(border_style);
let input_inner = input_block.inner(chunks[i]);
f.render_widget(input_block, chunks[i]);
let text = Paragraph::new(*value);
f.render_widget(text, input_inner);
if focused {
let x = input_inner.x + match *focus {
AddModalFocus::Url => app.add_input.visual_cursor() as u16,
AddModalFocus::Path => app.add_path_input.visual_cursor() as u16,
AddModalFocus::Category => app.add_category_input.visual_cursor() as u16,
AddModalFocus::Tags => app.add_tags_input.visual_cursor() as u16,
};
let y = input_inner.y;
*cursor_pos = Some((x, y)); *cursor_pos = Some((x, y));
} }
}
}
fn draw_delete_confirm(f: &mut Frame, app: &App) { fn draw_delete_confirm(f: &mut Frame, app: &App) {
let block = Block::default() let block = Block::default()