From bfa5549326091e4ad22731843e5b3c7eed741daf Mon Sep 17 00:00:00 2001 From: ewen Date: Sun, 10 May 2026 23:55:24 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + Cargo.toml | 47 +++ README.md | 44 +++ src/app.rs | 392 +++++++++++++++++++++++ src/events.rs | 309 ++++++++++++++++++ src/file_dialog.rs | 138 ++++++++ src/logo.rs | 35 ++ src/main.rs | 411 ++++++++++++++++++++++++ src/network.rs | 225 +++++++++++++ src/qbittorrent.rs | 378 ++++++++++++++++++++++ src/ui.rs | 776 +++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 2757 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/events.rs create mode 100644 src/file_dialog.rs create mode 100644 src/logo.rs create mode 100644 src/main.rs create mode 100644 src/network.rs create mode 100644 src/qbittorrent.rs create mode 100644 src/ui.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3c09058 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "qbit-tui" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +description = "A lightweight, aggressive terminal UI for controlling a remote qBittorrent instance" +license = "MIT" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } + +# TUI +ratatui = "0.26" +crossterm = "0.27" + +# HTTP client +reqwest = { version = "0.11", features = ["json", "cookies", "multipart"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling & utilities +anyhow = "1.0" +thiserror = "1.0" + +# CLI arguments (for future extensibility) +clap = { version = "4.4", features = ["derive"] } + +# Logging (optional but useful) +log = "0.4" +env_logger = "0.11" + +# URL encoding for form data +urlencoding = "2.1" + +# Human readable sizes +humansize = "2.1" + +# Text input for modals +tui-input = "0.8" + +# Date formatting for details panel +chrono = "0.4" + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..180888e --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# qbit-tui + +A lightweight terminal UI for controlling a remote qBittorrent instance. + +## What it does + +qbit-tui connects to a qBittorrent Web UI and displays your torrents in a real time table. You can pause, resume, delete torrents, add magnet links or .torrent files, toggle speed limits, and inspect detailed information including trackers, files, and peers. The interface features a sidebar for filtering by status, category, or tag, plus a details panel with four tabs. + +## Requirements + +* Rust toolchain (stable) +* A running qBittorrent instance with the Web UI enabled + +## Configuration + +The application reads connection settings from environment variables: + +* `QBIT_URL` : qBittorrent Web UI URL (default: http://localhost:8080) +* `QBIT_USER` : Username (default: admin) +* `QBIT_PASS` : Password (default: adminadmin) + +## Building + +```bash +cargo build --release +``` + +## Running + +```bash +# With defaults +cargo run + +# With custom credentials +QBIT_URL=http://192.168.1.10:8080 QBIT_USER=admin QBIT_PASS=secret cargo run +``` + +## Controls + +q: quit | Tab / h / l: change focus | ↑ / ↓ or k / j: navigate | p: pause/resume | x: delete | X: delete with files | i: toggle details panel | 1-4: switch detail tabs | a: add torrent file | m: add magnet | L: toggle speed limits + +## License + +MIT diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3d88a9d --- /dev/null +++ b/src/app.rs @@ -0,0 +1,392 @@ +use tui_input::Input; + +use crate::events::{ + Category, NetworkEvent, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo, +}; + +/// Current high-level screen of the application. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Login, + Main, +} + +/// Focus within the login screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginFocus { + Url, + Username, + Password, + Button, +} + +impl LoginFocus { + pub fn next(self) -> Self { + match self { + LoginFocus::Url => LoginFocus::Username, + LoginFocus::Username => LoginFocus::Password, + LoginFocus::Password => LoginFocus::Button, + LoginFocus::Button => LoginFocus::Url, + } + } + + pub fn prev(self) -> Self { + match self { + LoginFocus::Url => LoginFocus::Button, + LoginFocus::Username => LoginFocus::Url, + LoginFocus::Password => LoginFocus::Username, + LoginFocus::Button => LoginFocus::Password, + } + } +} + +/// Which UI panel currently holds the keyboard focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + TorrentList, + DetailsPanel, +} + +/// Which filter is currently selected in the sidebar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarFilter { + All, + Downloading, + Seeding, + Paused, + Error, + Category(String), + Tag(String), +} + +/// Active tab inside the details panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailsTab { + General, + Trackers, + Files, + Peers, +} + +impl DetailsTab { + pub fn next(self) -> Self { + match self { + DetailsTab::General => DetailsTab::Trackers, + DetailsTab::Trackers => DetailsTab::Files, + DetailsTab::Files => DetailsTab::Peers, + DetailsTab::Peers => DetailsTab::General, + } + } + + pub fn prev(self) -> Self { + match self { + DetailsTab::General => DetailsTab::Peers, + DetailsTab::Trackers => DetailsTab::General, + DetailsTab::Files => DetailsTab::Trackers, + DetailsTab::Peers => DetailsTab::Files, + } + } +} + +/// Global application state. Owned by the main thread, updated via NetworkEvents. +pub struct App { + pub running: bool, + pub screen: Screen, + pub authenticated: bool, + pub auth_error: Option, + + // Login screen state + pub login_url_input: Input, + pub login_user_input: Input, + pub login_pass_input: Input, + pub login_focus: LoginFocus, + + // Raw data from API + pub torrents: Vec, + pub categories: Vec, + pub tags: Vec, + pub transfer_info: TransferInfo, + pub speed_limits_mode: bool, + + // Sidebar state + pub focus: Focus, + pub sidebar_items: Vec, + pub sidebar_selected: usize, + + // Torrent list state + pub torrent_selected: usize, + + // Details panel state + pub show_details: bool, + pub details_tab: DetailsTab, + pub trackers: Vec, + pub files: Vec, + pub properties: Option, + pub peers: Vec, + + // Add modal state + pub show_add_modal: bool, + pub add_input: Input, + + // Delete modal state + pub show_delete_confirm: bool, + pub delete_with_files: bool, + + // Status bar + pub status_message: Option, + + // Credentials (kept for re-auth if needed) + pub base_url: String, + pub username: String, + pub password: String, +} + +impl App { + pub fn new(base_url: String, username: String, password: String) -> Self { + Self { + running: true, + screen: Screen::Login, + authenticated: false, + auth_error: None, + + login_url_input: Input::new(base_url.clone()), + login_user_input: Input::new(username.clone()), + login_pass_input: Input::new(password.clone()), + login_focus: LoginFocus::Url, + + torrents: Vec::new(), + categories: Vec::new(), + tags: Vec::new(), + transfer_info: TransferInfo::default(), + speed_limits_mode: false, + + focus: Focus::TorrentList, + sidebar_items: vec![ + SidebarFilter::All, + SidebarFilter::Downloading, + SidebarFilter::Seeding, + SidebarFilter::Paused, + SidebarFilter::Error, + ], + sidebar_selected: 0, + + torrent_selected: 0, + + show_details: false, + details_tab: DetailsTab::General, + trackers: Vec::new(), + files: Vec::new(), + properties: None, + peers: Vec::new(), + + show_add_modal: false, + add_input: Input::default(), + + show_delete_confirm: false, + delete_with_files: false, + + status_message: Some("Enter credentials and press Enter".to_string()), + base_url, + username, + password, + } + } + + /// Rebuild sidebar items whenever categories/tags change. + pub fn rebuild_sidebar(&mut self) { + let mut items = vec![ + SidebarFilter::All, + SidebarFilter::Downloading, + SidebarFilter::Seeding, + SidebarFilter::Paused, + SidebarFilter::Error, + ]; + for cat in &self.categories { + items.push(SidebarFilter::Category(cat.name.clone())); + } + for tag in &self.tags { + items.push(SidebarFilter::Tag(tag.clone())); + } + self.sidebar_items = items; + self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1)); + } + + /// Return the torrents visible after applying the current sidebar filter. + pub fn filtered_torrents(&self) -> Vec<&Torrent> { + let filter = self.sidebar_items.get(self.sidebar_selected); + self.torrents + .iter() + .filter(|t| match filter { + Some(SidebarFilter::All) => true, + Some(SidebarFilter::Downloading) => { + t.status == "downloading" + || t.status == "stalledDL" + || t.status == "metaDL" + || t.status == "forcedDL" + } + Some(SidebarFilter::Seeding) => { + t.status == "uploading" + || t.status == "stalledUP" + || t.status == "forcedUP" + } + Some(SidebarFilter::Paused) => { + t.status == "pausedUP" || t.status == "pausedDL" + || t.status == "stoppedUP" || t.status == "stoppedDL" + } + Some(SidebarFilter::Error) => { + t.status == "error" || t.status == "missingFiles" + } + Some(SidebarFilter::Category(c)) => &t.category == c, + Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag), + None => true, + }) + .collect() + } + + /// Get the hash of the torrent currently selected in the *filtered* list. + pub fn selected_torrent_hash(&self) -> Option { + let filtered = self.filtered_torrents(); + filtered.get(self.torrent_selected).map(|t| t.hash.clone()) + } + + /// Get the currently selected torrent (filtered view). + pub fn selected_torrent(&self) -> Option<&Torrent> { + let filtered = self.filtered_torrents(); + filtered.get(self.torrent_selected).copied() + } + + /// Process an incoming NetworkEvent and mutate state accordingly. + pub fn handle_network_event(&mut self, event: NetworkEvent) { + match event { + NetworkEvent::TorrentList(list) => { + self.torrents = list; + let count = self.filtered_torrents().len(); + if count > 0 { + self.torrent_selected = self.torrent_selected.min(count - 1); + } else { + self.torrent_selected = 0; + } + } + NetworkEvent::TransferInfo(info) => { + self.transfer_info = info; + } + NetworkEvent::Categories(cats) => { + self.categories = cats; + self.rebuild_sidebar(); + } + NetworkEvent::Tags(tags) => { + self.tags = tags; + self.rebuild_sidebar(); + } + NetworkEvent::SpeedLimitsMode(mode) => { + self.speed_limits_mode = mode; + } + NetworkEvent::AuthResult(Ok(())) => { + self.authenticated = true; + self.auth_error = None; + self.status_message = Some("Connected".to_string()); + } + NetworkEvent::AuthResult(Err(e)) => { + self.authenticated = false; + self.auth_error = Some(e.clone()); + self.status_message = Some(format!("Auth error: {}", e)); + } + NetworkEvent::ActionResult(Ok(msg)) => { + self.status_message = Some(msg); + } + NetworkEvent::ActionResult(Err(e)) => { + self.status_message = Some(format!("Action failed: {}", e)); + } + NetworkEvent::TorrentTrackers(list) => { + self.trackers = list; + } + NetworkEvent::TorrentFiles(list) => { + self.files = list; + } + NetworkEvent::TorrentProperties(props) => { + self.properties = Some(props); + } + NetworkEvent::TorrentPeers(list) => { + self.peers = list; + } + NetworkEvent::Error(e) => { + self.status_message = Some(format!("Network error: {}", e)); + } + } + } + + // ── Navigation helpers ────────────────────────────────────────────── + + pub fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::Sidebar => Focus::TorrentList, + Focus::TorrentList if self.show_details => Focus::DetailsPanel, + Focus::TorrentList => Focus::Sidebar, + Focus::DetailsPanel => Focus::Sidebar, + }; + } + + pub fn prev_focus(&mut self) { + self.focus = match self.focus { + Focus::Sidebar => { + if self.show_details { + Focus::DetailsPanel + } else { + Focus::TorrentList + } + } + Focus::TorrentList => Focus::Sidebar, + Focus::DetailsPanel => Focus::TorrentList, + }; + } + + pub fn next_sidebar(&mut self) { + if !self.sidebar_items.is_empty() { + self.sidebar_selected = (self.sidebar_selected + 1) % self.sidebar_items.len(); + self.torrent_selected = 0; + } + } + + pub fn previous_sidebar(&mut self) { + if !self.sidebar_items.is_empty() { + self.sidebar_selected = self.sidebar_selected.saturating_sub(1); + self.torrent_selected = 0; + } + } + + pub fn next_torrent(&mut self) { + let count = self.filtered_torrents().len(); + if count > 0 { + self.torrent_selected = (self.torrent_selected + 1) % count; + } + } + + pub fn previous_torrent(&mut self) { + let count = self.filtered_torrents().len(); + if count > 0 { + self.torrent_selected = self.torrent_selected.saturating_sub(1); + } + } + + pub fn next_tab(&mut self) { + self.details_tab = self.details_tab.next(); + } + + pub fn previous_tab(&mut self) { + self.details_tab = self.details_tab.prev(); + } + + pub fn toggle_details(&mut self) { + self.show_details = !self.show_details; + if self.show_details { + self.focus = Focus::DetailsPanel; + } else { + self.focus = Focus::TorrentList; + } + } + + pub fn quit(&mut self) { + self.running = false; + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..858a804 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,309 @@ +use serde::{Deserialize, Deserializer, Serialize}; + +/// Events sent from the Network Worker to the App via the MPSC channel. +#[derive(Debug, Clone)] +pub enum NetworkEvent { + /// New list of torrents fetched from the API + TorrentList(Vec), + /// Global transfer info (speeds, disk space) + TransferInfo(TransferInfo), + /// List of categories from sync/maindata + Categories(Vec), + /// List of tags from sync/maindata + Tags(Vec), + /// Current global speed limits mode (alternative = true) + SpeedLimitsMode(bool), + /// Authentication status changed + AuthResult(Result<(), String>), + /// Result of a pause/resume/delete/action + ActionResult(Result), + /// Detailed trackers for a torrent + TorrentTrackers(Vec), + /// Detailed files for a torrent + TorrentFiles(Vec), + /// Detailed properties for a torrent + TorrentProperties(TorrentProperties), + /// Peers for a torrent + TorrentPeers(Vec), + /// General error from the network layer + Error(String), +} + +/// Commands sent from the App to the Network Worker via the MPSC channel. +#[derive(Debug, Clone)] +pub enum Command { + /// Authenticate with the given credentials + Authenticate { username: String, password: String }, + /// Pause the given torrent hashes + Pause(Vec), + /// Resume the given torrent hashes + Resume(Vec), + /// Delete the given torrent hashes (with optional file deletion) + Delete { hashes: Vec, delete_files: bool }, + /// Add a torrent from magnet URL(s) + AddMagnet(String), + /// Add a torrent from a local .torrent file path + AddFile(String), + /// Toggle global speed limits mode + ToggleSpeedLimits, + /// Fetch trackers for a given hash + FetchTrackers(String), + /// Fetch files for a given hash + FetchFiles(String), + /// Fetch detailed properties for a given hash + FetchProperties(String), + /// Fetch peers for a given hash + FetchPeers(String), + /// Shutdown the network worker + Shutdown, +} + +// ── Custom deserializers ───────────────────────────────────────────── + +fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value: serde_json::Value = Deserialize::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Null => Ok(String::new()), + _ => Ok(String::new()), + } +} + +/// qBittorrent sometimes returns tags as a comma-separated string (torrents/info) +/// and sometimes as an array (sync/maindata). Accept both. +fn deserialize_tags<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: serde_json::Value = Deserialize::deserialize(deserializer)?; + match value { + serde_json::Value::Array(arr) => Ok(arr + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()), + serde_json::Value::String(s) => { + if s.is_empty() { + Ok(Vec::new()) + } else { + Ok(s.split(',').map(|t| t.trim().to_string()).collect()) + } + } + serde_json::Value::Null => Ok(Vec::new()), + _ => Ok(Vec::new()), + } +} + +/// Represents a torrent as returned by the qBittorrent API. +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Torrent { + pub hash: String, + pub name: String, + #[serde(default)] + pub size: i64, + #[serde(default)] + pub progress: f64, + #[serde(rename = "state", default)] + pub status: String, + #[serde(default)] + pub ratio: f64, + #[serde(rename = "dlspeed", default)] + pub dl_speed: i64, + #[serde(rename = "upspeed", default)] + pub ul_speed: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub category: String, + #[serde(default, deserialize_with = "deserialize_tags")] + pub tags: Vec, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub save_path: String, + #[serde(default)] + pub added_on: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub comment: String, + #[serde(default)] + pub num_seeds: i64, + #[serde(default)] + pub num_leechs: i64, + #[serde(default)] + pub popularity: f64, +} + +/// Global transfer information (matches `server_state` from sync/maindata). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct TransferInfo { + #[serde(default, rename = "dl_info_speed")] + pub dl_speed: i64, + #[serde(default, rename = "up_info_speed")] + pub ul_speed: i64, + #[serde(default, rename = "free_space_on_disk")] + pub free_space: i64, +} + +/// Category returned by sync/maindata. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Category { + #[serde(default)] + pub name: String, + #[serde(default, rename = "savePath")] + pub save_path: String, +} + +/// Tracker returned by /api/v2/torrents/trackers. +#[derive(Debug, Clone, Deserialize)] +pub struct Tracker { + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub url: String, + #[serde(default)] + pub status: i64, + #[serde(default)] + pub num_peers: i64, + #[serde(default)] + pub num_seeds: i64, + #[serde(default)] + pub num_leeches: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub msg: String, +} + +/// File returned by /api/v2/torrents/files. +#[derive(Debug, Clone, Deserialize)] +pub struct TorrentFile { + pub name: String, + #[serde(default)] + pub size: i64, + #[serde(default)] + pub progress: f64, + #[serde(default)] + pub priority: i64, + #[serde(rename = "is_seed")] + pub is_seed: Option, +} + +/// Detailed properties returned by /api/v2/torrents/properties. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct TorrentProperties { + pub hash: String, + pub name: String, + #[serde(default)] + pub size: i64, + #[serde(default)] + pub progress: f64, + #[serde(default)] + pub dlspeed: i64, + #[serde(default)] + pub upspeed: i64, + #[serde(default)] + pub priority: i64, + #[serde(default)] + pub num_seeds: i64, + #[serde(default)] + pub num_leechs: i64, + #[serde(default)] + pub num_complete: i64, + #[serde(default)] + pub num_incomplete: i64, + #[serde(default)] + pub ratio: f64, + #[serde(default)] + pub eta: i64, + #[serde(default)] + pub state: String, + #[serde(default)] + pub sequential_download: bool, + #[serde(default)] + pub last_activity: i64, + #[serde(default)] + pub total_downloaded: i64, + #[serde(default)] + pub total_uploaded: i64, + #[serde(default)] + pub total_downloaded_session: i64, + #[serde(default)] + pub total_uploaded_session: i64, + #[serde(default)] + pub upload_limit: i64, + #[serde(default)] + pub download_limit: i64, + #[serde(default)] + pub time_active: i64, + #[serde(default)] + pub seeding_time: i64, + #[serde(default)] + pub seeding_time_limit: i64, + #[serde(default)] + pub max_ratio: f64, + #[serde(default)] + pub max_seeding_time: i64, + #[serde(default)] + pub availability: f64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub save_path: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub download_path: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub comment: String, + #[serde(default)] + pub completion_on: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub created_by: String, + #[serde(default)] + pub total_size: i64, + #[serde(default)] + pub piece_size: i64, + #[serde(default)] + pub pieces_have: i64, + #[serde(default)] + pub pieces_num: i64, + #[serde(default)] + pub is_private: bool, + #[serde(default)] + pub addition_date: i64, + #[serde(default)] + pub completion_date: i64, + #[serde(default)] + pub creation_date: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub tracker: String, + #[serde(default)] + pub trackers_count: i64, + #[serde(default)] + pub peers: i64, + #[serde(default)] + pub seeds: i64, + #[serde(default)] + pub leechs: i64, + #[serde(default)] + pub popularity: f64, +} + +/// Peer returned by /api/v2/torrents/peers. +#[derive(Debug, Clone, Deserialize)] +pub struct Peer { + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub ip: String, + #[serde(default)] + pub port: i64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub connection: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub flags: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub client: String, + #[serde(default)] + pub progress: f64, + #[serde(default)] + pub dl_speed: i64, + #[serde(default)] + pub up_speed: i64, + #[serde(default)] + pub downloaded: i64, + #[serde(default)] + pub uploaded: i64, + #[serde(default)] + pub relevance: f64, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + pub files: String, +} diff --git a/src/file_dialog.rs b/src/file_dialog.rs new file mode 100644 index 0000000..e4cceb3 --- /dev/null +++ b/src/file_dialog.rs @@ -0,0 +1,138 @@ +use std::process::Command; + +/// Open a native file dialog to pick .torrent files. +/// Returns a list of absolute file paths selected by the user. +/// Platform-specific implementations: +/// - macOS: uses `osascript` (AppleScript / Finder) +/// - Linux: uses `zenity` or `kdialog` +/// - Windows: uses PowerShell +pub fn pick_torrent_files() -> Vec { + #[cfg(target_os = "macos")] + { + pick_macos() + } + + #[cfg(target_os = "linux")] + { + pick_linux() + } + + #[cfg(target_os = "windows")] + { + pick_windows() + } +} + +#[cfg(target_os = "macos")] +fn pick_macos() -> Vec { + // AppleScript that opens a Finder file picker restricted to .torrent files + let script = r#" + set fileList to {} + try + set theFiles to choose file with prompt "Select torrent files" of type {"torrent"} multiple selections allowed true + repeat with f in theFiles + set end of fileList to POSIX path of f + end repeat + on error + return "" + end try + set AppleScript's text item delimiters to "\n" + return fileList as text + "#; + + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + _ => Vec::new(), + } +} + +#[cfg(target_os = "linux")] +fn pick_linux() -> Vec { + // Try zenity first, then kdialog + let zenity = Command::new("zenity") + .args(&[ + "--file-selection", + "--multiple", + "--separator=\n", + "--file-filter=*.torrent", + "--title=Select torrent files", + ]) + .output(); + + if let Ok(out) = zenity { + if out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + + // Fallback to kdialog + let kdialog = Command::new("kdialog") + .args(&[ + "--getopenfilename", + ":", + "*.torrent", + "--multiple", + "--separate-output", + ]) + .output(); + + if let Ok(out) = kdialog { + if out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + + Vec::new() +} + +#[cfg(target_os = "windows")] +fn pick_windows() -> Vec { + let ps_script = r#" + Add-Type -AssemblyName System.Windows.Forms + $dlg = New-Object System.Windows.Forms.OpenFileDialog + $dlg.Filter = "Torrent files (*.torrent)|*.torrent" + $dlg.Multiselect = $true + $dlg.Title = "Select torrent files" + if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + $dlg.FileNames -join "`n" + } + "#; + + let output = Command::new("powershell") + .args(&["-Command", ps_script]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + _ => Vec::new(), + } +} diff --git a/src/logo.rs b/src/logo.rs new file mode 100644 index 0000000..bea5918 --- /dev/null +++ b/src/logo.rs @@ -0,0 +1,35 @@ +/// ASCII art of the qBittorrent logo, stored as raw strings to preserve +/// backslashes exactly as they appear in the terminal. +pub const LOGO: &[&str] = &[ + r##" '''`^^^`''' "##, + r##" '""""ll+1()(()_!l"""". "##, + r##" """I{1)))))(((((((||\|\l"""", "##, + r##" ^"^?1)))))))((((((||||\\\/\1""" "##, + r##" ^",1))))))((((((|||||\\\\////ttf,"^ "##, + r##" '"I1))))((((((((||||\\\///////ttttfft!"' "##, + r##" ""])))(((((((||||\\\/: //tttttffffffj("" "##, + r##" ":))((((((|||||\\/////: tttffffffjjjjjjj;" "##, + r##" "I((((((||||\\\//////tt: ffffffjjjjjjrrrrxl" "##, + r##" ,((((||||\\\/////tttttt; fffjjjjjrrrrxxxxxx, "##, + r##" ^"|||||||\|\|iI::;!]ft}i-tfff; jj_, `:jxxxxxxxnn"^ "##, + r##" '"{|||\\\t::;I[[}_:;~;;~ffjj; I >}}[' nxxnnnnnf"' "##, + r##" ^"|\\\|::It/ttttt/:::~fjjj; /rrxxxxn^ tnnnnuuu"^ "##, + r##" "!\///l::/tttfffff)::~jjjr; {xxxxxxnnn ^uuuuvvv<" "##, + r##"."1///////::Itffffffjj/;:~rrrrI fxxxnnnnnn' uvvvvvvj"."##, + r##"'")///tttt::Iffffjjjjjj:;+xxxxI xnnnnnnuuu` vvvvcccj"'"##, + r##"."(tttttff:;Ijjjjjjrrr/::~xxxxI /nnuuuuuvv' cccccccr"."##, + r##" "itffffffi:;rjjrrrrxx1::+nnnnI ]uuuvvvvvc ,cccczzz<" "##, + r##" ^"tfffjjjj;:;(rxxxxx?:::+nnnnI ~vvvvvv\ .czzzzzzX"^ "##, + r##" '"\jjjjrrrr~::::::::If:;+uuuuI >1. >zzzzzXXXu"' "##, + r##" ^"jjrrrrxxxxx1>>+unnn;;_uvvvczcccczi;l(zzXzXXXXYYX"^ "##, + r##" ":jrxxxxxxnnnnnnuuuu;;+vvvccccccczzzzzXXXXXYYYYY;" "##, + r##" "!rxxxnnnnnnuuuuvvu;;_cccccczzzzzzXXXXXYYYYYYXi" "##, + r##" ";xnnnnuuuuuuvvvvv;;_ccczzzzzzXXXXYYYYYYYYYYI" "##, + r##" ""\nuuuuvvvvvvvcc:;_czzzzzXXXXXYYYYYYYYYYj"" "##, + r##" '"invvvvvvccccccczzzzXXXXXXYYYYYYYYYYYzi"' "##, + r##" ^""vvccccccczzzzXXXXXYYYYYYYYYYYYYY,"` "##, + r##" ^""fcczzzzzXXXXXYYYYYYYYYYYYYYx",^ "##, + r##" """,!zXXXXXYYYYYYYYYYYYYYi,""" "##, + r##" '"""">~)zUYYUz(<>"""' "##, + r##" ''`^^^``'' "##, +]; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e637643 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,411 @@ +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 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> { + setup_panic_hook(); + + 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::(100); + let (net_tx, mut net_rx) = mpsc::channel::(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::(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_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>, + app: &mut App, + key_rx: &mut mpsc::Receiver, + net_rx: &mut mpsc::Receiver, + cmd_tx: &mpsc::Sender, + ticker: &mut tokio::time::Interval, +) -> Result<(), Box> { + 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() => { + 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>, + app: &App, +) -> Result<(), Box> { + 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: &App, cmd_tx: &mpsc::Sender) { + 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, +) -> Result<(), Box> { + 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(()); + } + + // ── Add Magnet Modal ─────────────────────────────────────────── + if app.show_add_modal { + match key.code { + KeyCode::Esc => { + app.show_add_modal = false; + app.add_input.reset(); + } + KeyCode::Enter => { + let urls = app.add_input.value().to_string(); + if !urls.is_empty() { + let _ = cmd_tx.send(Command::AddMagnet(urls)).await; + } + app.show_add_modal = false; + app.add_input.reset(); + } + _ => { + app.add_input.handle_event(&evt); + } + } + return Ok(()); + } + + // ── Delete Confirmation Modal ────────────────────────────────── + if app.show_delete_confirm { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(hash) = app.selected_torrent_hash() { + let _ = cmd_tx + .send(Command::Delete { + hashes: vec![hash], + delete_files: app.delete_with_files, + }) + .await; + } + app.show_delete_confirm = false; + } + 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; + } + + // 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; + } + + // 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 + KeyCode::Char('p') => { + 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" + || 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 + KeyCode::Char('x') => { + if 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() { + app.show_delete_confirm = true; + app.delete_with_files = false; + } + } + KeyCode::Char('X') => { + if app.selected_torrent_hash().is_some() { + app.show_delete_confirm = true; + app.delete_with_files = true; + } + } + + _ => {} + } + } + Ok(()) +} diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..8a206cd --- /dev/null +++ b/src/network.rs @@ -0,0 +1,225 @@ +use std::time::Duration; + +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::time::interval; + +use crate::events::{Command, NetworkEvent}; +use crate::qbittorrent::QbittorrentClient; + +/// Spawn the background network worker task. +pub fn spawn_network_worker( + base_url: String, + username: String, + password: String, + cmd_rx: Receiver, + event_tx: Sender, +) { + tokio::spawn(async move { + let mut client = QbittorrentClient::new(base_url, username.clone(), password.clone()); + let mut authenticated = false; + + let mut ticker = interval(Duration::from_secs(2)); + let mut cmd_rx = cmd_rx; + let mut auth_backoff = Duration::from_secs(2); + + loop { + tokio::select! { + _ = ticker.tick() => { + if !authenticated { + continue; + } + + if !client.is_authenticated() { + client.reset_session(); + match client.login().await { + Ok(()) => { + auth_backoff = Duration::from_secs(2); + let _ = event_tx.send(NetworkEvent::AuthResult(Ok(()))).await; + } + Err(e) => { + auth_backoff = std::cmp::min(auth_backoff * 2, Duration::from_secs(60)); + let _ = event_tx.send(NetworkEvent::AuthResult(Err(e.to_string()))).await; + tokio::time::sleep(auth_backoff).await; + continue; + } + } + } + + match client.get_torrents().await { + Ok(mut torrents) => { + // Enrich torrents with properties (popularity + seed/leech counts) + let mut set = tokio::task::JoinSet::new(); + for torrent in &torrents { + let hash = torrent.hash.clone(); + let c = client.clone(); + set.spawn(async move { + match c.get_torrent_properties(&hash).await { + Ok(props) => Some((hash, props)), + Err(_) => None, + } + }); + } + while let Some(res) = set.join_next().await { + if let Ok(Some((hash, props))) = res { + if let Some(t) = torrents.iter_mut().find(|t| t.hash == hash) { + t.num_seeds = props.num_seeds; + t.num_leechs = props.num_leechs; + t.popularity = props.popularity; + } + } + } + let _ = event_tx.send(NetworkEvent::TorrentList(torrents)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + + match client.get_maindata_extras().await { + Ok((info, cats, tags)) => { + let _ = event_tx.send(NetworkEvent::TransferInfo(info)).await; + let _ = event_tx.send(NetworkEvent::Categories(cats)).await; + let _ = event_tx.send(NetworkEvent::Tags(tags)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + } + + Some(cmd) = cmd_rx.recv() => { + match cmd { + Command::Authenticate { username, password } => { + client = QbittorrentClient::new( + client.base_url.clone(), + username, + password, + ); + match client.login().await { + Ok(()) => { + authenticated = true; + let _ = event_tx.send(NetworkEvent::AuthResult(Ok(()))).await; + if let Ok(mode) = client.get_speed_limits_mode().await { + let _ = event_tx.send(NetworkEvent::SpeedLimitsMode(mode)).await; + } + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::AuthResult(Err(e.to_string()))).await; + } + } + } + Command::Pause(hashes) => { + match client.pause_torrents(&hashes).await { + Ok(()) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) paused".to_string()))).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await; + } + } + } + Command::Resume(hashes) => { + match client.resume_torrents(&hashes).await { + Ok(()) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) resumed".to_string()))).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await; + } + } + } + Command::Delete { hashes, delete_files } => { + match client.delete_torrents(&hashes, delete_files).await { + Ok(()) => { + let msg = if delete_files { + "Torrent(s) and files deleted" + } else { + "Torrent(s) deleted" + }; + let _ = event_tx.send(NetworkEvent::ActionResult(Ok(msg.to_string()))).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await; + } + } + } + Command::AddMagnet(urls) => { + match client.add_torrents(&urls).await { + Ok(()) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) added".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(()) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent file added".to_string()))).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await; + } + } + } + Command::ToggleSpeedLimits => { + match client.toggle_speed_limits_mode().await { + Ok(()) => { + if let Ok(mode) = client.get_speed_limits_mode().await { + let _ = event_tx.send(NetworkEvent::SpeedLimitsMode(mode)).await; + } + let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Speed limits toggled".to_string()))).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await; + } + } + } + Command::FetchTrackers(hash) => { + match client.get_torrent_trackers(&hash).await { + Ok(list) => { + let _ = event_tx.send(NetworkEvent::TorrentTrackers(list)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + } + Command::FetchFiles(hash) => { + match client.get_torrent_files(&hash).await { + Ok(list) => { + let _ = event_tx.send(NetworkEvent::TorrentFiles(list)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + } + Command::FetchProperties(hash) => { + match client.get_torrent_properties(&hash).await { + Ok(props) => { + let _ = event_tx.send(NetworkEvent::TorrentProperties(props)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + } + Command::FetchPeers(hash) => { + match client.get_torrent_peers(&hash).await { + Ok(list) => { + let _ = event_tx.send(NetworkEvent::TorrentPeers(list)).await; + } + Err(e) => { + let _ = event_tx.send(NetworkEvent::Error(e.to_string())).await; + } + } + } + Command::Shutdown => break, + } + } + } + } + }); +} diff --git a/src/qbittorrent.rs b/src/qbittorrent.rs new file mode 100644 index 0000000..a8c4f38 --- /dev/null +++ b/src/qbittorrent.rs @@ -0,0 +1,378 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, Response}; + +use crate::events::{Category, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo}; + +/// qBittorrent Web API client with session cookie management. +#[derive(Clone)] +pub struct QbittorrentClient { + client: Client, + pub base_url: String, + username: String, + password: String, + is_authenticated: bool, +} + +impl QbittorrentClient { + pub fn new(base_url: String, username: String, password: String) -> Self { + let client = Client::builder() + .cookie_store(true) + .build() + .expect("Failed to build HTTP client"); + + Self { + client, + base_url, + username, + password, + is_authenticated: false, + } + } + + /// Authenticate and store the session cookie. + pub async fn login(&mut self) -> Result<()> { + let url = format!("{}/api/v2/auth/login", self.base_url); + let params = [ + ("username", self.username.as_str()), + ("password", self.password.as_str()), + ]; + + let resp = self + .client + .post(&url) + .form(¶ms) + .send() + .await + .context("Login request failed")?; + + let status = resp.status(); + let body = resp.text().await.context("Failed to read login response")?; + let body_trimmed = body.trim(); + + if status.is_success() && (body_trimmed == "Ok." || status.as_u16() == 204) { + self.is_authenticated = true; + Ok(()) + } else if status.is_success() && body_trimmed == "Fails." { + self.is_authenticated = false; + anyhow::bail!("Authentication failed: invalid username or password") + } else if status.as_u16() == 403 { + self.is_authenticated = false; + anyhow::bail!("Authentication failed: IP is banned (too many failed attempts)") + } else { + self.is_authenticated = false; + anyhow::bail!( + "Authentication failed (HTTP {}): {}", + status, + body_trimmed + ) + } + } + + pub fn is_authenticated(&self) -> bool { + self.is_authenticated + } + + pub fn reset_session(&mut self) { + self.client = Client::builder() + .cookie_store(true) + .build() + .expect("Failed to build HTTP client"); + self.is_authenticated = false; + } + + /// Fetch the list of torrents. + pub async fn get_torrents(&self) -> Result> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/info", self.base_url); + let resp = self.client.get(&url).send().await?; + if resp.status().as_u16() == 204 { + return Ok(Vec::new()); + } + self.handle_response(resp).await + } + + /// Fetch global state, categories and tags from sync/maindata. + /// Defensively parses every field to avoid crashing on unexpected types. + pub async fn get_maindata_extras(&self) -> Result<(TransferInfo, Vec, Vec)> { + self.require_auth()?; + let url = format!("{}/api/v2/sync/maindata?rid=0", self.base_url); + let resp = self.client.get(&url).send().await?; + + if resp.status().as_u16() == 204 { + return Ok((TransferInfo::default(), Vec::new(), Vec::new())); + } + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, text); + } + + let data: serde_json::Value = resp.json().await?; + + // --- server_state --- + let state = data.get("server_state").map(|s| { + let dl = s.get("dl_info_speed") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let ul = s.get("up_info_speed") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let free = s.get("free_space_on_disk") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + TransferInfo { dl_speed: dl, ul_speed: ul, free_space: free } + }).unwrap_or_default(); + + // --- categories --- + let categories: Vec = data + .get("categories") + .and_then(|c| c.as_object()) + .map(|obj| { + obj.values() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() + }) + .unwrap_or_default(); + + // --- tags --- + let tags: Vec = match data.get("tags") { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + Some(serde_json::Value::String(s)) if s.is_empty() => Vec::new(), + Some(other) => { + serde_json::from_value(other.clone()).unwrap_or_default() + } + None => Vec::new(), + }; + + Ok((state, categories, tags)) + } + + /// Pause (stop) torrents by hash. + pub async fn pause_torrents(&self, hashes: &[String]) -> Result<()> { + self.require_auth()?; + let hashes_str = hashes.join("|"); + for endpoint in ["stop", "pause"] { + let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint); + let resp = self + .client + .post(&url) + .form(&[("hashes", hashes_str.clone())]) + .send() + .await?; + if resp.status().is_success() { + return Ok(()); + } + if endpoint == "pause" && resp.status().as_u16() == 404 { + continue; + } + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "HTTP {} for pause (hash={}): {}", + status, + hashes_str, + body + ); + } + Ok(()) + } + + /// Resume (start) torrents by hash. + pub async fn resume_torrents(&self, hashes: &[String]) -> Result<()> { + self.require_auth()?; + let hashes_str = hashes.join("|"); + for endpoint in ["start", "resume"] { + let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint); + let resp = self + .client + .post(&url) + .form(&[("hashes", hashes_str.clone())]) + .send() + .await?; + if resp.status().is_success() { + return Ok(()); + } + if endpoint == "resume" && resp.status().as_u16() == 404 { + continue; + } + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "HTTP {} for resume (hash={}): {}", + status, + hashes_str, + body + ); + } + Ok(()) + } + + /// Delete torrents by hash. + pub async fn delete_torrents(&self, hashes: &[String], delete_files: bool) -> Result<()> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/delete", self.base_url); + let hashes_str = hashes.join("|"); + let resp = self + .client + .post(&url) + .form(&[ + ("hashes", hashes_str.as_str()), + ("deleteFiles", if delete_files { "true" } else { "false" }), + ]) + .send() + .await?; + resp.error_for_status()?; + Ok(()) + } + + /// Add torrent(s) from magnet URL(s) or torrent file URL(s). + pub async fn add_torrents(&self, urls: &str) -> Result<()> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/add", self.base_url); + let resp = self + .client + .post(&url) + .form(&[("urls", urls)]) + .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()?; + let url = format!("{}/api/v2/torrents/add", self.base_url); + + let file_content = tokio::fs::read(file_path).await + .with_context(|| format!("Failed to read torrent file: {}", file_path))?; + let file_name = std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("torrent.torrent"); + + let part = reqwest::multipart::Part::bytes(file_content) + .file_name(file_name.to_string()); + let form = reqwest::multipart::Form::new() + .part("torrents", part); + + let resp = self + .client + .post(&url) + .multipart(form) + .send() + .await?; + resp.error_for_status()?; + Ok(()) + } + + /// Toggle alternative speed limits. + pub async fn toggle_speed_limits_mode(&self) -> Result<()> { + self.require_auth()?; + let url = format!("{}/api/v2/transfer/toggleSpeedLimitsMode", self.base_url); + let resp = self.client.post(&url).send().await?; + resp.error_for_status()?; + Ok(()) + } + + /// Get current speed limits mode (true = alternative). + pub async fn get_speed_limits_mode(&self) -> Result { + self.require_auth()?; + let url = format!("{}/api/v2/transfer/speedLimitsMode", self.base_url); + let resp = self.client.get(&url).send().await?; + let text = resp.text().await?; + Ok(text.trim() == "1") + } + + /// Fetch trackers for a given torrent hash. + pub async fn get_torrent_trackers(&self, hash: &str) -> Result> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/trackers?hash={}", self.base_url, hash); + let resp = self.client.get(&url).send().await?; + self.handle_response(resp).await + } + + /// Fetch files for a given torrent hash. + pub async fn get_torrent_files(&self, hash: &str) -> Result> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/files?hash={}", self.base_url, hash); + let resp = self.client.get(&url).send().await?; + self.handle_response(resp).await + } + + /// Fetch detailed properties for a given torrent hash. + pub async fn get_torrent_properties(&self, hash: &str) -> Result { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/properties?hash={}", self.base_url, hash); + let resp = self.client.get(&url).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {} for properties: {}", status, text); + } + + let body = resp.text().await?; + if body.trim().is_empty() { + return Ok(TorrentProperties::default()); + } + + let props: TorrentProperties = serde_json::from_str(&body) + .with_context(|| format!("Failed to parse properties JSON: {}", &body[..body.len().min(200)]))?; + Ok(props) + } + + /// Fetch peers for a given torrent hash. + pub async fn get_torrent_peers(&self, hash: &str) -> Result> { + self.require_auth()?; + let url = format!("{}/api/v2/torrents/peers?hash={}", self.base_url, hash); + let resp = self.client.get(&url).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {} for peers: {}", status, text); + } + + let body = resp.text().await?; + if body.trim().is_empty() { + return Ok(Vec::new()); + } + + // The response is {"peers": {"peer_id": {Peer}, ...}} + let data: serde_json::Value = serde_json::from_str(&body) + .with_context(|| format!("Failed to parse peers JSON: {}", &body[..body.len().min(200)]))?; + let peers: Vec = data + .get("peers") + .and_then(|p| p.as_object()) + .map(|obj| { + obj.values() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() + }) + .unwrap_or_default(); + Ok(peers) + } + + fn require_auth(&self) -> Result<()> { + if !self.is_authenticated { + anyhow::bail!("Not authenticated") + } + Ok(()) + } + + async fn handle_response(&self, resp: Response) -> Result { + if resp.status().is_success() { + let json = resp.json::().await?; + Ok(json) + } else { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, text) + } + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e3f993a --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,776 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use humansize::{format_size, DECIMAL}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table, Wrap}, + Frame, +}; + +use crate::app::{App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter}; +use crate::logo; + +fn format_eta(seconds: i64) -> String { + if seconds < 0 { + "∞".to_string() + } else if seconds < 60 { + format!("{}s", seconds) + } else if seconds < 3600 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else if seconds < 86400 { + format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60) + } else { + format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) + } +} + +fn format_duration(seconds: i64) -> String { + if seconds < 60 { + format!("{}s", seconds) + } else if seconds < 3600 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else if seconds < 86400 { + format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60) + } else { + format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) + } +} + +/// Map a raw qBittorrent state string to a human-readable label and color. +fn map_status(state: &str) -> (&str, Color) { + match state { + "error" | "missingFiles" => ("Error", Color::Red), + "uploading" | "stalledUP" => ("Seeding", Color::Green), + "downloading" => ("Downloading", Color::Blue), + "stalledDL" => ("Stalled", Color::Yellow), + "metaDL" => ("Metadata", Color::Cyan), + "pausedUP" | "pausedDL" | "stoppedUP" | "stoppedDL" => ("Stopped", Color::Gray), + "queuedUP" | "queuedDL" => ("Queued", Color::Magenta), + "checkingUP" | "checkingDL" | "checkingResumeData" => ("Checking", Color::LightYellow), + "forcedUP" => ("Forced Up", Color::LightGreen), + "forcedDL" => ("Forced Down", Color::LightBlue), + "moving" => ("Moving", Color::LightCyan), + _ => (state, Color::White), + } +} + +fn focus_border(focus: Focus, current: Focus) -> Style { + if focus == current { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + } +} + +/// 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)>) { + if app.screen == Screen::Login { + draw_login_screen(f, app, cursor_pos); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // Body + Constraint::Length(5), // Footer (2 lines + borders) + ]) + .split(f.size()); + + draw_header(f, app, chunks[0]); + draw_body(f, app, chunks[1]); + draw_footer(f, app, chunks[2]); + + if app.show_add_modal { + draw_add_modal(f, app, cursor_pos); + } + + if app.show_delete_confirm { + draw_delete_confirm(f, app); + } +} + +fn draw_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let alt_text = if app.speed_limits_mode { " [ALT]" } else { "" }; + let title = format!(" qbit-tui{} ", alt_text); + + let header_block = Block::default() + .title(title) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = header_block.inner(area); + f.render_widget(header_block, area); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(inner); + + let dl_text = format_size(app.transfer_info.dl_speed as u64, DECIMAL); + let ul_text = format_size(app.transfer_info.ul_speed as u64, DECIMAL); + let free_text = if app.transfer_info.free_space > 0 { + format_size(app.transfer_info.free_space as u64, DECIMAL) + } else { + "N/A".to_string() + }; + + let dl = Paragraph::new(format!("▼ DL: {}/s", dl_text)).alignment(Alignment::Center); + let ul = Paragraph::new(format!("▲ UL: {}/s", ul_text)).alignment(Alignment::Center); + let free = Paragraph::new(format!("Free: {}", free_text)).alignment(Alignment::Center); + + f.render_widget(dl, cols[0]); + f.render_widget(ul, cols[1]); + f.render_widget(free, cols[2]); +} + +fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let body_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) + .split(area); + + draw_sidebar(f, app, body_chunks[0]); + + if app.show_details { + let right_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]); + } else { + draw_torrent_list(f, app, body_chunks[1]); + } +} + +fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let items: Vec = app + .sidebar_items + .iter() + .enumerate() + .map(|(i, filter)| { + let label = match filter { + SidebarFilter::All => "Tous".to_string(), + SidebarFilter::Downloading => "Téléchargement".to_string(), + SidebarFilter::Seeding => "Seeding".to_string(), + SidebarFilter::Paused => "En Pause".to_string(), + SidebarFilter::Error => "Erreur".to_string(), + SidebarFilter::Category(c) => format!("📁 {}", c), + SidebarFilter::Tag(t) => format!("🏷️ {}", t), + }; + let style = if i == app.sidebar_selected { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(label).style(style) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" Filtres ") + .border_style(focus_border(Focus::Sidebar, app.focus)), + ); + + f.render_widget(list, area); +} + +fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let header_cells = ["Name", "Size", "Progress", "Status", "Ratio", "DL", "UL", "Seeds", "Leechers", "Popularity"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); + let header = Row::new(header_cells) + .style(Style::default().fg(Color::Yellow)) + .height(1); + + let filtered = app.filtered_torrents(); + let rows: Vec = filtered + .iter() + .enumerate() + .map(|(i, t)| { + let progress = format!("{:.1}%", t.progress * 100.0); + let size = format_size(t.size as u64, DECIMAL); + let dl = format_size(t.dl_speed as u64, DECIMAL); + let ul = format_size(t.ul_speed as u64, DECIMAL); + let ratio = format!("{:.2}", t.ratio); + let seeds = if t.num_seeds >= 0 { + t.num_seeds.to_string() + } else { + "-".to_string() + }; + let leechs = if t.num_leechs >= 0 { + t.num_leechs.to_string() + } else { + "-".to_string() + }; + let popularity = if t.popularity >= 0.0 { + format!("{:.2}", t.popularity) + } else { + "-".to_string() + }; + + let (status_label, status_color) = map_status(&t.status); + let status_cell = Cell::from(Line::from(vec![Span::styled( + status_label, + Style::default().fg(status_color), + )])); + + let cells = vec![ + Cell::from(t.name.clone()), + Cell::from(size), + Cell::from(progress), + status_cell, + Cell::from(ratio), + Cell::from(format!("{}/s", dl)), + Cell::from(format!("{}/s", ul)), + Cell::from(seeds), + Cell::from(leechs), + Cell::from(popularity), + ]; + + let style = if i == app.torrent_selected { + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + Row::new(cells).style(style).height(1) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Percentage(28), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Length(9), + Constraint::Length(10), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Torrents ") + .border_style(focus_border(Focus::TorrentList, app.focus)), + ); + + f.render_widget(table, area); +} + +fn draw_details_panel(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let tab_label = match app.details_tab { + DetailsTab::General => "Général", + DetailsTab::Trackers => "Trackers", + DetailsTab::Files => "Fichiers", + DetailsTab::Peers => "Peers", + }; + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Détails — {} ", tab_label)) + .border_style(focus_border(Focus::DetailsPanel, app.focus)); + + let inner = block.inner(area); + f.render_widget(block, area); + + match app.details_tab { + DetailsTab::General => draw_details_general(f, app, inner), + DetailsTab::Trackers => draw_details_trackers(f, app, inner), + DetailsTab::Files => draw_details_files(f, app, inner), + DetailsTab::Peers => draw_details_peers(f, app, inner), + } +} + +fn draw_details_general(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + if let Some(props) = &app.properties { + let added = UNIX_EPOCH + Duration::from_secs(props.addition_date.max(0) as u64); + let datetime = chrono::DateTime::::from(added); + let added_str = datetime.format("%Y-%m-%d %H:%M").to_string(); + + let completion = if props.completion_date > 0 { + let dt = UNIX_EPOCH + Duration::from_secs(props.completion_date as u64); + chrono::DateTime::::from(dt).format("%Y-%m-%d %H:%M").to_string() + } else { + "Incomplete".to_string() + }; + + let creation = if props.creation_date > 0 { + let dt = UNIX_EPOCH + Duration::from_secs(props.creation_date as u64); + chrono::DateTime::::from(dt).format("%Y-%m-%d %H:%M").to_string() + } else { + "N/A".to_string() + }; + + let text = format!( + "Hash: {}\n\ + Name: {}\n\ + Save Path: {}\n\ + Download Path: {}\n\ + Added: {}\n\ + Completed: {}\n\ + Created: {} (by {})\n\ + Comment: {}\n\ + Status: {}\n\ + Progress: {:.1}%\n\ + Availability: {:.2}\n\ + ─────────────────────────────────────────\n\ + Size: {} ({} pieces of {} each)\n\ + Downloaded: {} (session: {})\n\ + Uploaded: {} (session: {})\n\ + Ratio: {:.2} / limit {:.2}\n\ + ─────────────────────────────────────────\n\ + DL Speed: {}/s (limit: {}/s)\n\ + UL Speed: {}/s (limit: {}/s)\n\ + ETA: {}\n\ + Active: {}\n\ + Seeding Time: {} / limit {}\n\ + Last Activity: {}\n\ + ─────────────────────────────────────────\n\ + Peers: {} connected ({} seeds / {} leechs)\n\ + Swarm: {} complete / {} incomplete\n\ + Trackers: {}\n\ + Sequential DL: {}", + props.hash, + props.name, + props.save_path, + if props.download_path.is_empty() { "N/A" } else { &props.download_path }, + added_str, + completion, + creation, + if props.created_by.is_empty() { "Unknown" } else { &props.created_by }, + if props.comment.is_empty() { "N/A" } else { &props.comment }, + map_status(&props.state).0, + props.progress * 100.0, + props.availability, + format_size(props.total_size as u64, DECIMAL), + props.pieces_num, + format_size(props.piece_size as u64, DECIMAL), + format_size(props.total_downloaded as u64, DECIMAL), + format_size(props.total_downloaded_session as u64, DECIMAL), + format_size(props.total_uploaded as u64, DECIMAL), + format_size(props.total_uploaded_session as u64, DECIMAL), + props.ratio, + props.max_ratio, + format_size(props.dlspeed as u64, DECIMAL), + format_size(props.download_limit as u64, DECIMAL), + format_size(props.upspeed as u64, DECIMAL), + format_size(props.upload_limit as u64, DECIMAL), + format_eta(props.eta), + format_duration(props.time_active), + format_duration(props.seeding_time), + format_duration(props.seeding_time_limit), + format_duration(props.last_activity), + props.peers, + props.seeds, + props.leechs, + props.num_complete, + props.num_incomplete, + props.trackers_count, + if props.sequential_download { "Yes" } else { "No" }, + ); + let par = Paragraph::new(text).wrap(Wrap { trim: true }); + f.render_widget(par, area); + } else { + f.render_widget(Paragraph::new("Select a torrent to see detailed properties"), area); + } +} + +fn draw_details_trackers(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let header_cells = ["URL", "Status", "Peers", "Seeds", "Leeches", "Msg"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); + let header = Row::new(header_cells) + .style(Style::default().fg(Color::Yellow)) + .height(1); + + let rows: Vec = app + .trackers + .iter() + .map(|tr| { + let status = match tr.status { + 0 => "Disabled", + 1 => "Not contacted", + 2 => "Working", + 3 => "Updating", + 4 => "Not working", + _ => "Unknown", + }; + Row::new(vec![ + Cell::from(tr.url.clone()), + Cell::from(status), + Cell::from(tr.num_peers.to_string()), + Cell::from(tr.num_seeds.to_string()), + Cell::from(tr.num_leeches.to_string()), + Cell::from(tr.msg.clone()), + ]) + .height(1) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Percentage(35), + Constraint::Length(12), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Percentage(25), + ], + ) + .header(header); + + f.render_widget(table, area); +} + +fn draw_details_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let header_cells = ["Name", "Size", "Progress", "Priority", "Done"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); + let header = Row::new(header_cells) + .style(Style::default().fg(Color::Yellow)) + .height(1); + + let rows: Vec = app + .files + .iter() + .map(|file| { + let done = match file.is_seed { + Some(true) => "Yes", + Some(false) => "No", + None => "?", + }; + Row::new(vec![ + Cell::from(file.name.clone()), + Cell::from(format_size(file.size as u64, DECIMAL)), + Cell::from(format!("{:.1}%", file.progress * 100.0)), + Cell::from(file.priority.to_string()), + Cell::from(done), + ]) + .height(1) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Percentage(50), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + ], + ) + .header(header); + + f.render_widget(table, area); +} + +fn draw_details_peers(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let header_cells = ["IP:Port", "Client", "Progress", "DL", "UL", "Flags", "Files"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); + let header = Row::new(header_cells) + .style(Style::default().fg(Color::Yellow)) + .height(1); + + let rows: Vec = app + .peers + .iter() + .map(|peer| { + let ip_port = format!("{}:{}", peer.ip, peer.port); + let progress = format!("{:.1}%", peer.progress * 100.0); + let dl = format_size(peer.dl_speed as u64, DECIMAL); + let ul = format_size(peer.up_speed as u64, DECIMAL); + let client = if peer.client.is_empty() { + "Unknown".to_string() + } else { + peer.client.clone() + }; + let flags = if peer.flags.is_empty() { + "-".to_string() + } else { + peer.flags.clone() + }; + let files = if peer.files.is_empty() { + "All".to_string() + } else { + peer.files.clone() + }; + Row::new(vec![ + Cell::from(ip_port), + Cell::from(client), + Cell::from(progress), + Cell::from(format!("{}/s", dl)), + Cell::from(format!("{}/s", ul)), + Cell::from(flags), + Cell::from(files), + ]) + .height(1) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(22), + Constraint::Length(18), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(10), + Constraint::Percentage(20), + ], + ) + .header(header); + + f.render_widget(table, area); +} + +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", "") + } else if app.show_add_modal { + ("Enter: Submit | Esc: Cancel", "") + } 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", + ) + } else { + ( + "q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet", + "h/l:Change Focus", + ) + }; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = footer_block.inner(area); + f.render_widget(footer_block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let status_par = Paragraph::new(status).alignment(Alignment::Left); + f.render_widget(status_par, rows[0]); + + if help_bottom.is_empty() { + let help_par = Paragraph::new(help_top).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); + f.render_widget(help_par, help_cols[0]); + let help2_par = Paragraph::new(help_bottom).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 block = Block::default() + .title(" Add Magnet / URL(s) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + + let inner = block.inner(area); + 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); + + // 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)); +} + +fn draw_delete_confirm(f: &mut Frame, app: &App) { + let block = Block::default() + .title(" Confirm Deletion ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)); + + let text = if app.delete_with_files { + "Delete torrent AND local files? (y/n)" + } else { + "Delete torrent from list? (y/n)" + }; + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + let area = centered_rect(40, 20, f.size()); + f.render_widget(Clear, area); + f.render_widget(paragraph, area); +} + +fn draw_login_screen(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) { + let area = f.size(); + + // Background block + let bg = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + f.render_widget(bg, area); + + let inner = area.inner(&ratatui::layout::Margin { horizontal: 2, vertical: 1 }); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), // Logo + Constraint::Length(1), // Spacer + Constraint::Length(3), // URL + Constraint::Length(3), // Username + Constraint::Length(3), // Password + Constraint::Length(3), // Button + Constraint::Min(1), // Status + ]) + .split(inner); + + // ── ASCII Logo (colored blue/cyan like qBittorrent) ─────────────── + let logo_lines: Vec = logo::LOGO + .iter() + .enumerate() + .map(|(i, line)| { + let style = if i == 0 || i == logo::LOGO.len().saturating_sub(1) { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue) + }; + Line::styled(*line, style) + }) + .collect(); + + let logo = Paragraph::new(logo_lines) + .alignment(Alignment::Center); + f.render_widget(logo, chunks[0]); + + // ── Form fields ────────────────────────────────────────────────── + draw_login_input(f, " URL ", app.login_url_input.value(), app.login_focus == LoginFocus::Url, chunks[2]); + draw_login_input(f, " Username ", app.login_user_input.value(), app.login_focus == LoginFocus::Username, chunks[3]); + draw_login_input(f, " Password ", app.login_pass_input.value(), app.login_focus == LoginFocus::Password, chunks[4]); + + // Button + let btn_style = if app.login_focus == LoginFocus::Button { + Style::default().bg(Color::Cyan).fg(Color::Black).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let btn = Paragraph::new(" [ Connect ] ") + .alignment(Alignment::Center) + .style(btn_style) + .block(Block::default().borders(Borders::ALL).border_style(if app.login_focus == LoginFocus::Button { Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::DarkGray) })); + f.render_widget(btn, chunks[5]); + + // Status / Error message + let status = app.status_message.as_deref().unwrap_or("Tab to navigate, Enter to connect"); + let status_style = if app.auth_error.is_some() { + Style::default().fg(Color::Red) + } else if app.authenticated { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Gray) + }; + let status_par = Paragraph::new(status) + .alignment(Alignment::Center) + .style(status_style); + f.render_widget(status_par, chunks[6]); + + // Cursor positioning for active input + match app.login_focus { + LoginFocus::Url => { + let x = chunks[2].x + 2 + app.login_url_input.visual_cursor() as u16; + let y = chunks[2].y + 1; + *cursor_pos = Some((x, y)); + } + LoginFocus::Username => { + let x = chunks[3].x + 2 + app.login_user_input.visual_cursor() as u16; + let y = chunks[3].y + 1; + *cursor_pos = Some((x, y)); + } + LoginFocus::Password => { + let x = chunks[4].x + 2 + app.login_pass_input.visual_cursor() as u16; + let y = chunks[4].y + 1; + *cursor_pos = Some((x, y)); + } + LoginFocus::Button => { + *cursor_pos = None; + } + } +} + +fn draw_login_input(f: &mut Frame, label: &str, value: &str, focused: bool, area: ratatui::layout::Rect) { + let border_style = if focused { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let block = Block::default() + .title(label) + .borders(Borders::ALL) + .border_style(border_style); + let inner = block.inner(area); + f.render_widget(block, area); + + let text = Paragraph::new(value); + f.render_widget(text, inner); +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +}