diff --git a/Cargo.toml b/Cargo.toml index 3c09058..1f74872 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,4 +44,10 @@ tui-input = "0.8" # Date formatting for details panel chrono = "0.4" +# Desktop notifications +notify-rust = "4.11" + +# File logging +simplelog = { version = "0.12", features = ["paris"] } + diff --git a/src/app.rs b/src/app.rs index 3d88a9d..8348dda 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,43 @@ +use std::collections::HashSet; + use tui_input::Input; use crate::events::{ 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { @@ -69,6 +103,26 @@ pub enum DetailsTab { 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 { pub fn next(self) -> Self { match self { @@ -125,9 +179,26 @@ pub struct App { pub properties: Option, pub peers: Vec, + // 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, + // Add modal state pub show_add_modal: bool, + pub add_modal_focus: AddModalFocus, 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 pub show_delete_confirm: bool, @@ -180,8 +251,22 @@ impl App { properties: None, 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, + add_modal_focus: AddModalFocus::Url, 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, delete_with_files: false, @@ -212,10 +297,13 @@ impl App { 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> { 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() .filter(|t| match filter { Some(SidebarFilter::All) => true, @@ -241,7 +329,32 @@ impl App { Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag), 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. @@ -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) { self.running = false; } diff --git a/src/events.rs b/src/events.rs index 858a804..3d913d9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -44,6 +44,15 @@ pub enum Command { AddMagnet(String), /// Add a torrent from a local .torrent file path AddFile(String), + /// Add torrent(s) with advanced options + AddTorrentAdvanced { + urls: String, + save_path: String, + category: String, + tags: Vec, + paused: bool, + skip_checking: bool, + }, /// Toggle global speed limits mode ToggleSpeedLimits, /// Fetch trackers for a given hash @@ -54,6 +63,26 @@ pub enum Command { FetchProperties(String), /// Fetch peers for a given hash FetchPeers(String), + /// Recheck torrents by hash + Recheck(Vec), + /// Relocate torrents to a new path + Relocate { hashes: Vec, new_path: String }, + /// Set upload/download limits for torrents + SetTorrentLimits { hashes: Vec, ul_limit: i64, dl_limit: i64 }, + /// Set category for torrents + SetCategory { hashes: Vec, category: String }, + /// Create a new category + CreateCategory { name: String, save_path: String }, + /// Delete a category + DeleteCategory(String), + /// Add tags to torrents + AddTags { hashes: Vec, tags: Vec }, + /// Create a new tag + CreateTag(String), + /// Delete a tag + DeleteTag(String), + /// Set file priority + SetFilePriority { hash: String, file_ids: Vec, priority: i64 }, /// Shutdown the network worker Shutdown, } @@ -307,3 +336,82 @@ pub struct Peer { #[serde(default, deserialize_with = "deserialize_string_or_null")] 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, ""); + } +} diff --git a/src/main.rs b/src/main.rs index e637643..d6cc959 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use crossterm::{ 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}; @@ -29,6 +30,7 @@ use crate::ui::draw; #[tokio::main] async fn main() -> Result<(), Box> { setup_panic_hook(); + setup_logging(); enable_raw_mode()?; let mut stdout = io::stdout(); @@ -89,6 +91,29 @@ async fn main() -> Result<(), Box> { 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| { @@ -118,6 +143,19 @@ async fn run_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 { @@ -133,7 +171,7 @@ async fn run_app( /// Render the frame and position the physical cursor when the add-modal is active. fn draw_and_cursor( terminal: &mut Terminal>, - app: &App, + app: &mut App, ) -> Result<(), Box> { let mut cursor_pos: Option<(u16, u16)> = None; terminal.draw(|f| draw(f, app, &mut cursor_pos))?; @@ -232,23 +270,87 @@ async fn handle_input( 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 { 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 _ = 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.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 => {} + } } _ => { - app.add_input.handle_event(&evt); + 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(()); @@ -258,15 +360,23 @@ async fn handle_input( if app.show_delete_confirm { match key.code { 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 .send(Command::Delete { - hashes: vec![hash], + 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; @@ -322,6 +432,29 @@ async fn handle_input( 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(); @@ -340,6 +473,20 @@ async fn handle_input( 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 { @@ -362,14 +509,14 @@ async fn handle_input( } } - // Pause / Resume + // Pause / Resume (supports multi-selection) KeyCode::Char('p') => { - if let Some(torrent) = app.selected_torrent() { + if !app.selected_hashes.is_empty() { + let hashes: Vec = 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(); - if hash.is_empty() { - app.status_message = Some("Error: selected torrent has empty hash".to_string()); - return Ok(()); - } let is_paused = torrent.status == "pausedDL" || torrent.status == "pausedUP" || torrent.status == "stoppedDL" @@ -384,21 +531,21 @@ async fn handle_input( } } - // Delete + // Delete (supports multi-selection) 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.delete_with_files = false; } } 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.delete_with_files = false; } } 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.delete_with_files = true; } diff --git a/src/network.rs b/src/network.rs index 8a206cd..ca0db68 100644 --- a/src/network.rs +++ b/src/network.rs @@ -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) => { match client.add_torrent_file(&path).await { 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, } } diff --git a/src/qbittorrent.rs b/src/qbittorrent.rs index a8c4f38..12f7221 100644 --- a/src/qbittorrent.rs +++ b/src/qbittorrent.rs @@ -243,6 +243,46 @@ impl QbittorrentClient { 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(¶ms) + .send() + .await?; + resp.error_for_status()?; + Ok(()) + } + /// Add a torrent from a local .torrent file. pub async fn add_torrent_file(&self, file_path: &str) -> Result<()> { self.require_auth()?; @@ -358,6 +398,169 @@ impl QbittorrentClient { 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::>().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<()> { if !self.is_authenticated { anyhow::bail!("Not authenticated") diff --git a/src/ui.rs b/src/ui.rs index e3f993a..1eb11ec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter}; +use crate::app::{AddModalFocus, App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter}; use crate::logo; 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. /// If the add-modal is active, `cursor_pos` is filled with the screen coordinates /// 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 { draw_login_screen(f, app, cursor_pos); return; @@ -134,7 +134,7 @@ fn draw_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { 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() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) @@ -142,18 +142,50 @@ fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { draw_sidebar(f, app, body_chunks[0]); - if app.show_details { - let right_chunks = Layout::default() + let right_area = body_chunks[1]; + + if app.show_search { + let search_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) - .split(body_chunks[1]); - draw_torrent_list(f, app, right_chunks[0]); - draw_details_panel(f, app, right_chunks[1]); + .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 { - draw_torrent_list(f, app, body_chunks[1]); + if app.show_details { + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(right_area); + draw_torrent_list(f, app, right_chunks[0]); + draw_details_panel(f, app, right_chunks[1]); + } else { + 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) { let items: Vec = app .sidebar_items @@ -241,10 +273,19 @@ fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { Cell::from(popularity), ]; + let is_selected = app.selected_hashes.contains(&t.hash); let style = if i == app.torrent_selected { - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + if is_selected { + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } + } else if is_selected { + Style::default().bg(Color::Blue) } else { 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) { let status = app.status_message.as_deref().unwrap_or("Ready"); - let (help_top, help_bottom) = if app.show_delete_confirm { - ("y: Confirm | n: Cancel", "") + let selected_count = app.selected_hashes.len(); + 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 { - ("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 { ( - "q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet", - "1:Général 2:Trackers 3:Fichiers 4:Peers [:Prev ]:Next", + 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 s:Select S:ClearSel .:Sort ,:Dir /:Search".to_string(), ) } else { ( - "q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet", - "h/l:Change Focus", + format!("q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet{}", selection_hint), + "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]); 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]); } else { let help_cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .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]); - 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]); } } 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() - .title(" Add Magnet / URL(s) ") + .title(" Add Torrent — URLs / Path / Category / Tags ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Green)); @@ -611,13 +661,51 @@ fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) f.render_widget(Clear, area); f.render_widget(block, area); - let text = Paragraph::new(app.add_input.value()).wrap(Wrap { trim: true }); - f.render_widget(text, inner); + let chunks = Layout::default() + .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 x = inner.x + app.add_input.visual_cursor() as u16; - let y = inner.y; - *cursor_pos = Some((x, y)); + let fields = [ + (AddModalFocus::Url, " URLs ", app.add_input.value()), + (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)); + } + } } fn draw_delete_confirm(f: &mut Frame, app: &App) {