Initial commit
This commit is contained in:
commit
bfa5549326
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
47
Cargo.toml
Normal file
47
Cargo.toml
Normal 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
44
README.md
Normal 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
392
src/app.rs
Normal 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
309
src/events.rs
Normal 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
138
src/file_dialog.rs
Normal 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
35
src/logo.rs
Normal 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
411
src/main.rs
Normal 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
225
src/network.rs
Normal 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
378
src/qbittorrent.rs
Normal 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(¶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<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
776
src/ui.rs
Normal 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]
|
||||
}
|
||||
Loading…
Reference in a new issue