qbit-tui/src/qbittorrent.rs

582 lines
19 KiB
Rust

use anyhow::{Context, Result};
use reqwest::{Client, Response};
use crate::events::{Category, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo};
/// qBittorrent Web API client with session cookie management.
#[derive(Clone)]
pub struct QbittorrentClient {
client: Client,
pub base_url: String,
username: String,
password: String,
is_authenticated: bool,
}
impl QbittorrentClient {
pub fn new(base_url: String, username: String, password: String) -> Self {
let client = Client::builder()
.cookie_store(true)
.build()
.expect("Failed to build HTTP client");
Self {
client,
base_url,
username,
password,
is_authenticated: false,
}
}
/// Authenticate and store the session cookie.
pub async fn login(&mut self) -> Result<()> {
let url = format!("{}/api/v2/auth/login", self.base_url);
let params = [
("username", self.username.as_str()),
("password", self.password.as_str()),
];
let resp = self
.client
.post(&url)
.form(&params)
.send()
.await
.context("Login request failed")?;
let status = resp.status();
let body = resp.text().await.context("Failed to read login response")?;
let body_trimmed = body.trim();
if status.is_success() && (body_trimmed == "Ok." || status.as_u16() == 204) {
self.is_authenticated = true;
Ok(())
} else if status.is_success() && body_trimmed == "Fails." {
self.is_authenticated = false;
anyhow::bail!("Authentication failed: invalid username or password")
} else if status.as_u16() == 403 {
self.is_authenticated = false;
anyhow::bail!("Authentication failed: IP is banned (too many failed attempts)")
} else {
self.is_authenticated = false;
anyhow::bail!(
"Authentication failed (HTTP {}): {}",
status,
body_trimmed
)
}
}
pub fn is_authenticated(&self) -> bool {
self.is_authenticated
}
pub fn reset_session(&mut self) {
self.client = Client::builder()
.cookie_store(true)
.build()
.expect("Failed to build HTTP client");
self.is_authenticated = false;
}
/// Fetch the list of torrents.
pub async fn get_torrents(&self) -> Result<Vec<Torrent>> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/info", self.base_url);
let resp = self.client.get(&url).send().await?;
if resp.status().as_u16() == 204 {
return Ok(Vec::new());
}
self.handle_response(resp).await
}
/// Fetch global state, categories and tags from sync/maindata.
/// Defensively parses every field to avoid crashing on unexpected types.
pub async fn get_maindata_extras(&self) -> Result<(TransferInfo, Vec<Category>, Vec<String>)> {
self.require_auth()?;
let url = format!("{}/api/v2/sync/maindata?rid=0", self.base_url);
let resp = self.client.get(&url).send().await?;
if resp.status().as_u16() == 204 {
return Ok((TransferInfo::default(), Vec::new(), Vec::new()));
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status, text);
}
let data: serde_json::Value = resp.json().await?;
// --- server_state ---
let state = data.get("server_state").map(|s| {
let dl = s.get("dl_info_speed")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let ul = s.get("up_info_speed")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let free = s.get("free_space_on_disk")
.and_then(|v| v.as_i64())
.unwrap_or(0);
TransferInfo { dl_speed: dl, ul_speed: ul, free_space: free }
}).unwrap_or_default();
// --- categories ---
let categories: Vec<Category> = data
.get("categories")
.and_then(|c| c.as_object())
.map(|obj| {
obj.values()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
})
.unwrap_or_default();
// --- tags ---
let tags: Vec<String> = match data.get("tags") {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
Some(serde_json::Value::String(s)) if s.is_empty() => Vec::new(),
Some(other) => {
serde_json::from_value(other.clone()).unwrap_or_default()
}
None => Vec::new(),
};
Ok((state, categories, tags))
}
/// Pause (stop) torrents by hash.
pub async fn pause_torrents(&self, hashes: &[String]) -> Result<()> {
self.require_auth()?;
let hashes_str = hashes.join("|");
for endpoint in ["stop", "pause"] {
let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str.clone())])
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
if endpoint == "pause" && resp.status().as_u16() == 404 {
continue;
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"HTTP {} for pause (hash={}): {}",
status,
hashes_str,
body
);
}
Ok(())
}
/// Resume (start) torrents by hash.
pub async fn resume_torrents(&self, hashes: &[String]) -> Result<()> {
self.require_auth()?;
let hashes_str = hashes.join("|");
for endpoint in ["start", "resume"] {
let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str.clone())])
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
if endpoint == "resume" && resp.status().as_u16() == 404 {
continue;
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"HTTP {} for resume (hash={}): {}",
status,
hashes_str,
body
);
}
Ok(())
}
/// Delete torrents by hash.
pub async fn delete_torrents(&self, hashes: &[String], delete_files: bool) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/delete", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[
("hashes", hashes_str.as_str()),
("deleteFiles", if delete_files { "true" } else { "false" }),
])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Add torrent(s) from magnet URL(s) or torrent file URL(s).
pub async fn add_torrents(&self, urls: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/add", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("urls", urls)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Add torrent(s) with advanced options.
pub async fn add_torrents_advanced(
&self,
urls: &str,
save_path: &str,
category: &str,
tags: &[String],
paused: bool,
skip_checking: bool,
) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/add", self.base_url);
let mut params = vec![
("urls", urls.to_string()),
];
if !save_path.is_empty() {
params.push(("savepath", save_path.to_string()));
}
if !category.is_empty() {
params.push(("category", category.to_string()));
}
if !tags.is_empty() {
params.push(("tags", tags.join(",")));
}
if paused {
params.push(("paused", "true".to_string()));
}
if skip_checking {
params.push(("skip_checking", "true".to_string()));
}
let resp = self
.client
.post(&url)
.form(&params)
.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/sync/torrentPeers?hash={}&rid=0", 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)
}
/// Recheck torrents by hash.
pub async fn recheck_torrents(&self, hashes: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/recheck", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Relocate torrents to a new path.
pub async fn relocate_torrents(&self, hashes: &[String], new_path: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/setLocation", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("location", new_path.to_string())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Set upload/download limits for torrents (bytes/s, -1 = unlimited).
pub async fn set_torrent_limits(&self, hashes: &[String], dl_limit: i64, ul_limit: i64) -> Result<()> {
self.require_auth()?;
let hashes_str = hashes.join("|");
if dl_limit >= 0 {
let url = format!("{}/api/v2/torrents/setDownloadLimit", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str.clone()), ("limit", dl_limit.to_string())])
.send()
.await?;
resp.error_for_status()?;
}
if ul_limit >= 0 {
let url = format!("{}/api/v2/torrents/setUploadLimit", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("limit", ul_limit.to_string())])
.send()
.await?;
resp.error_for_status()?;
}
Ok(())
}
/// Set category for torrents.
pub async fn set_category(&self, hashes: &[String], category: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/setCategory", self.base_url);
let hashes_str = hashes.join("|");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("category", category.to_string())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Create a new category.
pub async fn create_category(&self, name: &str, save_path: &str) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/createCategory", self.base_url);
let resp = self
.client
.post(&url)
.form(&[("category", name), ("savePath", save_path)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Delete categories.
pub async fn delete_categories(&self, names: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/removeCategories", self.base_url);
let categories = names.join("\n");
let resp = self
.client
.post(&url)
.form(&[("categories", categories)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Add tags to torrents.
pub async fn add_tags(&self, hashes: &[String], tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/addTags", self.base_url);
let hashes_str = hashes.join("|");
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("hashes", hashes_str), ("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Create new tags.
pub async fn create_tags(&self, tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/createTags", self.base_url);
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Delete tags.
pub async fn delete_tags(&self, tags: &[String]) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/deleteTags", self.base_url);
let tags_str = tags.join(",");
let resp = self
.client
.post(&url)
.form(&[("tags", tags_str)])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
/// Set file priority for a torrent.
pub async fn set_file_priority(&self, hash: &str, file_ids: &[i64], priority: i64) -> Result<()> {
self.require_auth()?;
let url = format!("{}/api/v2/torrents/filePrio", self.base_url);
let ids = file_ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",");
let prio = priority.to_string();
let resp = self
.client
.post(&url)
.form(&[("hash", hash), ("id", ids.as_str()), ("priority", prio.as_str())])
.send()
.await?;
resp.error_for_status()?;
Ok(())
}
fn require_auth(&self) -> Result<()> {
if !self.is_authenticated {
anyhow::bail!("Not authenticated")
}
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)
}
}
}