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