Initial commit

This commit is contained in:
ewen 2026-05-10 23:55:24 +02:00
commit bfa5549326
11 changed files with 2757 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

47
Cargo.toml Normal file
View file

@ -0,0 +1,47 @@
[package]
name = "qbit-tui"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
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"

44
README.md Normal file
View file

@ -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

392
src/app.rs Normal file
View file

@ -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<String>,
// 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<Torrent>,
pub categories: Vec<Category>,
pub tags: Vec<String>,
pub transfer_info: TransferInfo,
pub speed_limits_mode: bool,
// Sidebar state
pub focus: Focus,
pub sidebar_items: Vec<SidebarFilter>,
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<Tracker>,
pub files: Vec<TorrentFile>,
pub properties: Option<TorrentProperties>,
pub peers: Vec<Peer>,
// 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<String>,
// 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<String> {
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;
}
}

309
src/events.rs Normal file
View file

@ -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<Torrent>),
/// Global transfer info (speeds, disk space)
TransferInfo(TransferInfo),
/// List of categories from sync/maindata
Categories(Vec<Category>),
/// List of tags from sync/maindata
Tags(Vec<String>),
/// Current global speed limits mode (alternative = true)
SpeedLimitsMode(bool),
/// Authentication status changed
AuthResult(Result<(), String>),
/// Result of a pause/resume/delete/action
ActionResult(Result<String, String>),
/// Detailed trackers for a torrent
TorrentTrackers(Vec<Tracker>),
/// Detailed files for a torrent
TorrentFiles(Vec<TorrentFile>),
/// Detailed properties for a torrent
TorrentProperties(TorrentProperties),
/// Peers for a torrent
TorrentPeers(Vec<Peer>),
/// 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<String>),
/// Resume the given torrent hashes
Resume(Vec<String>),
/// Delete the given torrent hashes (with optional file deletion)
Delete { hashes: Vec<String>, 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<String, D::Error>
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<Vec<String>, 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<String>,
#[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<bool>,
}
/// 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,
}

138
src/file_dialog.rs Normal file
View file

@ -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<String> {
#[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<String> {
// 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<String> {
// 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<String> {
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(),
}
}

35
src/logo.rs Normal file
View file

@ -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##" ''`^^^``'' "##,
];

411
src/main.rs Normal file
View file

@ -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<dyn std::error::Error>> {
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::<Command>(100);
let (net_tx, mut net_rx) = mpsc::channel::<NetworkEvent>(100);
// The worker is spawned immediately but waits for an Authenticate command.
// We pass the credentials from env so the user sees them pre-filled.
spawn_network_worker(base_url, username, password, cmd_rx, net_tx);
let (key_tx, mut key_rx) = mpsc::channel::<Event>(100);
tokio::task::spawn_blocking(move || {
loop {
if let Ok(evt) = event::read() {
if key_tx.blocking_send(evt).is_err() {
break;
}
}
}
});
let mut ticker = tokio::time::interval(Duration::from_millis(16));
let result = run_app(
&mut terminal,
&mut app,
&mut key_rx,
&mut net_rx,
&cmd_tx,
&mut ticker,
)
.await;
let _ = cmd_tx.send(Command::Shutdown).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = result {
eprintln!("Application error: {:?}", err);
}
Ok(())
}
fn setup_panic_hook() {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
original_hook(info);
}));
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key_rx: &mut mpsc::Receiver<Event>,
net_rx: &mut mpsc::Receiver<NetworkEvent>,
cmd_tx: &mpsc::Sender<Command>,
ticker: &mut tokio::time::Interval,
) -> Result<(), Box<dyn std::error::Error>> {
while app.running {
tokio::select! {
_ = ticker.tick() => {
draw_and_cursor(terminal, app)?;
}
Some(evt) = key_rx.recv() => {
handle_input(evt, app, cmd_tx).await?;
draw_and_cursor(terminal, app)?;
}
Some(net_evt) = net_rx.recv() => {
app.handle_network_event(net_evt);
// Switch from login to main on successful auth
if app.screen == app::Screen::Login && app.authenticated {
app.screen = app::Screen::Main;
}
draw_and_cursor(terminal, app)?;
}
}
}
Ok(())
}
/// Render the frame and position the physical cursor when the add-modal is active.
fn draw_and_cursor(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &App,
) -> Result<(), Box<dyn std::error::Error>> {
let mut cursor_pos: Option<(u16, u16)> = None;
terminal.draw(|f| draw(f, app, &mut cursor_pos))?;
if let Some((x, y)) = cursor_pos {
terminal.set_cursor(x, y)?;
terminal.show_cursor()?;
} else {
terminal.hide_cursor()?;
}
Ok(())
}
/// Send tracker/file/property fetch commands when the details panel is open.
async fn refresh_details(app: &App, cmd_tx: &mpsc::Sender<Command>) {
if !app.show_details {
return;
}
if let Some(hash) = app.selected_torrent_hash() {
match app.details_tab {
app::DetailsTab::General => {
let _ = cmd_tx.send(Command::FetchProperties(hash)).await;
}
app::DetailsTab::Trackers => {
let _ = cmd_tx.send(Command::FetchTrackers(hash)).await;
}
app::DetailsTab::Files => {
let _ = cmd_tx.send(Command::FetchFiles(hash)).await;
}
app::DetailsTab::Peers => {
let _ = cmd_tx.send(Command::FetchPeers(hash)).await;
}
}
}
}
async fn handle_input(
evt: Event,
app: &mut App,
cmd_tx: &mpsc::Sender<Command>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Event::Key(key) = evt {
if key.kind != KeyEventKind::Press {
return Ok(());
}
// ── Login Screen ───────────────────────────────────────────────
if app.screen == app::Screen::Login {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.quit(),
KeyCode::Tab => {
app.login_focus = app.login_focus.next();
}
KeyCode::BackTab => {
app.login_focus = app.login_focus.prev();
}
KeyCode::Enter => {
if app.login_focus == app::LoginFocus::Button {
let url = app.login_url_input.value().to_string();
let user = app.login_user_input.value().to_string();
let pass = app.login_pass_input.value().to_string();
app.base_url = url.clone();
app.username = user.clone();
app.password = pass.clone();
app.status_message = Some("Authenticating...".to_string());
let _ = cmd_tx
.send(Command::Authenticate {
username: user,
password: pass,
})
.await;
} else {
app.login_focus = app.login_focus.next();
}
}
KeyCode::Up => {
app.login_focus = app.login_focus.prev();
}
KeyCode::Down => {
app.login_focus = app.login_focus.next();
}
_ => {
match app.login_focus {
app::LoginFocus::Url => {
app.login_url_input.handle_event(&evt);
}
app::LoginFocus::Username => {
app.login_user_input.handle_event(&evt);
}
app::LoginFocus::Password => {
app.login_pass_input.handle_event(&evt);
}
app::LoginFocus::Button => {}
}
}
}
return Ok(());
}
// ── 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(())
}

225
src/network.rs Normal file
View file

@ -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<Command>,
event_tx: Sender<NetworkEvent>,
) {
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,
}
}
}
}
});
}

378
src/qbittorrent.rs Normal file
View file

@ -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(&params)
.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<Vec<Torrent>> {
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<Category>, Vec<String>)> {
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<Category> = 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<String> = 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<bool> {
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<Vec<Tracker>> {
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<Vec<TorrentFile>> {
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<TorrentProperties> {
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<Vec<Peer>> {
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<Peer> = 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<T: serde::de::DeserializeOwned>(&self, resp: Response) -> Result<T> {
if resp.status().is_success() {
let json = resp.json::<T>().await?;
Ok(json)
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status, text)
}
}
}

776
src/ui.rs Normal file
View file

@ -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<ListItem> = 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<Row> = 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::<chrono::Local>::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::<chrono::Local>::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::<chrono::Local>::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<Row> = 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<Row> = 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<Row> = 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<Line> = 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]
}