582 lines
19 KiB
Rust
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(¶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 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<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)
|
|
}
|
|
}
|
|
}
|