use anyhow::{Context, Result}; use reqwest::{Client, Response}; use crate::events::{Category, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo}; /// qBittorrent Web API client with session cookie management. #[derive(Clone)] pub struct QbittorrentClient { client: Client, pub base_url: String, username: String, password: String, is_authenticated: bool, } impl QbittorrentClient { pub fn new(base_url: String, username: String, password: String) -> Self { let client = Client::builder() .cookie_store(true) .build() .expect("Failed to build HTTP client"); Self { client, base_url, username, password, is_authenticated: false, } } /// Authenticate and store the session cookie. pub async fn login(&mut self) -> Result<()> { let url = format!("{}/api/v2/auth/login", self.base_url); let params = [ ("username", self.username.as_str()), ("password", self.password.as_str()), ]; let resp = self .client .post(&url) .form(¶ms) .send() .await .context("Login request failed")?; let status = resp.status(); let body = resp.text().await.context("Failed to read login response")?; let body_trimmed = body.trim(); if status.is_success() && (body_trimmed == "Ok." || status.as_u16() == 204) { self.is_authenticated = true; Ok(()) } else if status.is_success() && body_trimmed == "Fails." { self.is_authenticated = false; anyhow::bail!("Authentication failed: invalid username or password") } else if status.as_u16() == 403 { self.is_authenticated = false; anyhow::bail!("Authentication failed: IP is banned (too many failed attempts)") } else { self.is_authenticated = false; anyhow::bail!( "Authentication failed (HTTP {}): {}", status, body_trimmed ) } } pub fn is_authenticated(&self) -> bool { self.is_authenticated } pub fn reset_session(&mut self) { self.client = Client::builder() .cookie_store(true) .build() .expect("Failed to build HTTP client"); self.is_authenticated = false; } /// Fetch the list of torrents. pub async fn get_torrents(&self) -> Result> { self.require_auth()?; let url = format!("{}/api/v2/torrents/info", self.base_url); let resp = self.client.get(&url).send().await?; if resp.status().as_u16() == 204 { return Ok(Vec::new()); } self.handle_response(resp).await } /// Fetch global state, categories and tags from sync/maindata. /// Defensively parses every field to avoid crashing on unexpected types. pub async fn get_maindata_extras(&self) -> Result<(TransferInfo, Vec, Vec)> { self.require_auth()?; let url = format!("{}/api/v2/sync/maindata?rid=0", self.base_url); let resp = self.client.get(&url).send().await?; if resp.status().as_u16() == 204 { return Ok((TransferInfo::default(), Vec::new(), Vec::new())); } if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("HTTP {}: {}", status, text); } let data: serde_json::Value = resp.json().await?; // --- server_state --- let state = data.get("server_state").map(|s| { let dl = s.get("dl_info_speed") .and_then(|v| v.as_i64()) .unwrap_or(0); let ul = s.get("up_info_speed") .and_then(|v| v.as_i64()) .unwrap_or(0); let free = s.get("free_space_on_disk") .and_then(|v| v.as_i64()) .unwrap_or(0); TransferInfo { dl_speed: dl, ul_speed: ul, free_space: free } }).unwrap_or_default(); // --- categories --- let categories: Vec = data .get("categories") .and_then(|c| c.as_object()) .map(|obj| { obj.values() .filter_map(|v| serde_json::from_value(v.clone()).ok()) .collect() }) .unwrap_or_default(); // --- tags --- let tags: Vec = match data.get("tags") { Some(serde_json::Value::Array(arr)) => arr .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(), Some(serde_json::Value::String(s)) if s.is_empty() => Vec::new(), Some(other) => { serde_json::from_value(other.clone()).unwrap_or_default() } None => Vec::new(), }; Ok((state, categories, tags)) } /// Pause (stop) torrents by hash. pub async fn pause_torrents(&self, hashes: &[String]) -> Result<()> { self.require_auth()?; let hashes_str = hashes.join("|"); for endpoint in ["stop", "pause"] { let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint); let resp = self .client .post(&url) .form(&[("hashes", hashes_str.clone())]) .send() .await?; if resp.status().is_success() { return Ok(()); } if endpoint == "pause" && resp.status().as_u16() == 404 { continue; } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!( "HTTP {} for pause (hash={}): {}", status, hashes_str, body ); } Ok(()) } /// Resume (start) torrents by hash. pub async fn resume_torrents(&self, hashes: &[String]) -> Result<()> { self.require_auth()?; let hashes_str = hashes.join("|"); for endpoint in ["start", "resume"] { let url = format!("{}/api/v2/torrents/{}", self.base_url, endpoint); let resp = self .client .post(&url) .form(&[("hashes", hashes_str.clone())]) .send() .await?; if resp.status().is_success() { return Ok(()); } if endpoint == "resume" && resp.status().as_u16() == 404 { continue; } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!( "HTTP {} for resume (hash={}): {}", status, hashes_str, body ); } Ok(()) } /// Delete torrents by hash. pub async fn delete_torrents(&self, hashes: &[String], delete_files: bool) -> Result<()> { self.require_auth()?; let url = format!("{}/api/v2/torrents/delete", self.base_url); let hashes_str = hashes.join("|"); let resp = self .client .post(&url) .form(&[ ("hashes", hashes_str.as_str()), ("deleteFiles", if delete_files { "true" } else { "false" }), ]) .send() .await?; resp.error_for_status()?; Ok(()) } /// Add torrent(s) from magnet URL(s) or torrent file URL(s). pub async fn add_torrents(&self, urls: &str) -> Result<()> { self.require_auth()?; let url = format!("{}/api/v2/torrents/add", self.base_url); let resp = self .client .post(&url) .form(&[("urls", urls)]) .send() .await?; resp.error_for_status()?; Ok(()) } /// Add 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(¶ms) .send() .await?; resp.error_for_status()?; Ok(()) } /// Add a torrent from a local .torrent file. pub async fn add_torrent_file(&self, file_path: &str) -> Result<()> { self.require_auth()?; let url = format!("{}/api/v2/torrents/add", self.base_url); let file_content = tokio::fs::read(file_path).await .with_context(|| format!("Failed to read torrent file: {}", file_path))?; let file_name = std::path::Path::new(file_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("torrent.torrent"); let part = reqwest::multipart::Part::bytes(file_content) .file_name(file_name.to_string()); let form = reqwest::multipart::Form::new() .part("torrents", part); let resp = self .client .post(&url) .multipart(form) .send() .await?; resp.error_for_status()?; Ok(()) } /// Toggle alternative speed limits. pub async fn toggle_speed_limits_mode(&self) -> Result<()> { self.require_auth()?; let url = format!("{}/api/v2/transfer/toggleSpeedLimitsMode", self.base_url); let resp = self.client.post(&url).send().await?; resp.error_for_status()?; Ok(()) } /// Get current speed limits mode (true = alternative). pub async fn get_speed_limits_mode(&self) -> Result { self.require_auth()?; let url = format!("{}/api/v2/transfer/speedLimitsMode", self.base_url); let resp = self.client.get(&url).send().await?; let text = resp.text().await?; Ok(text.trim() == "1") } /// Fetch trackers for a given torrent hash. pub async fn get_torrent_trackers(&self, hash: &str) -> Result> { self.require_auth()?; let url = format!("{}/api/v2/torrents/trackers?hash={}", self.base_url, hash); let resp = self.client.get(&url).send().await?; self.handle_response(resp).await } /// Fetch files for a given torrent hash. pub async fn get_torrent_files(&self, hash: &str) -> Result> { self.require_auth()?; let url = format!("{}/api/v2/torrents/files?hash={}", self.base_url, hash); let resp = self.client.get(&url).send().await?; self.handle_response(resp).await } /// Fetch detailed properties for a given torrent hash. pub async fn get_torrent_properties(&self, hash: &str) -> Result { self.require_auth()?; let url = format!("{}/api/v2/torrents/properties?hash={}", self.base_url, hash); let resp = self.client.get(&url).send().await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("HTTP {} for properties: {}", status, text); } let body = resp.text().await?; if body.trim().is_empty() { return Ok(TorrentProperties::default()); } let props: TorrentProperties = serde_json::from_str(&body) .with_context(|| format!("Failed to parse properties JSON: {}", &body[..body.len().min(200)]))?; Ok(props) } /// Fetch peers for a given torrent hash. pub async fn get_torrent_peers(&self, hash: &str) -> Result> { self.require_auth()?; let url = format!("{}/api/v2/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 = 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::>().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(&self, resp: Response) -> Result { if resp.status().is_success() { let json = resp.json::().await?; Ok(json) } else { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("HTTP {}: {}", status, text) } } }