use crate::config::QbtProfile;
use anyhow::{Context, bail};
use std::time::Duration;
pub struct QbtClient {
endpoint: String,
http: reqwest::blocking::Client,
sid: Option<String>,
version: String,
}
impl std::fmt::Debug for QbtClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QbtClient")
.field("endpoint", &self.endpoint)
.field("sid", &self.sid.as_ref().map(|_| "<redacted>"))
.field("version", &self.version)
.finish()
}
}
impl QbtClient {
pub fn connect(profile: &QbtProfile) -> anyhow::Result<QbtClient> {
let http = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(15))
.build()
.context("创建 qBittorrent HTTP 客户端")?;
let mut client = QbtClient {
endpoint: profile.endpoint.trim_end_matches('/').to_string(),
http,
sid: None,
version: String::new(),
};
if !profile.username.is_empty() {
client.login(&profile.username, &profile.password)?;
}
client.version = client.fetch_version()?;
Ok(client)
}
pub fn version(&self) -> &str {
&self.version
}
fn unreachable_hint(&self) -> String {
format!(
"无法连接到 qBittorrent({})—— WebUI 是否已启用?",
self.endpoint
)
}
fn login(&mut self, username: &str, password: &str) -> anyhow::Result<()> {
let response = self
.http
.post(format!("{}/api/v2/auth/login", self.endpoint))
.form(&[("username", username), ("password", password)])
.send()
.with_context(|| self.unreachable_hint())?;
let status = response.status();
let sid = response
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.filter_map(|c| c.split(';').next())
.map(str::trim)
.find(|kv| {
let name = kv.split('=').next().unwrap_or_default();
name == "SID" || name.starts_with("QBT_SID_")
})
.map(str::to_string);
let body = response.text().unwrap_or_default();
if !status.is_success() || body.trim() == "Fails." {
bail!("qBittorrent 登录失败 —— 请检查用户名/密码");
}
self.sid = sid;
Ok(())
}
fn with_cookie(&self, builder: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder {
match &self.sid {
Some(sid) => builder.header(reqwest::header::COOKIE, sid.clone()),
None => builder,
}
}
fn fetch_version(&self) -> anyhow::Result<String> {
let response = self
.with_cookie(self.http.get(format!("{}/api/v2/app/version", self.endpoint)))
.send()
.with_context(|| self.unreachable_hint())?
.error_for_status()
.context("qBittorrent 拒绝了版本请求")?;
Ok(response.text().context("读取版本响应")?.trim().to_string())
}
pub fn ensure_category(&self, name: &str) -> anyhow::Result<()> {
let response = self
.with_cookie(self.http.post(format!("{}/api/v2/torrents/createCategory", self.endpoint)))
.form(&[("category", name), ("savePath", "")])
.send()
.with_context(|| self.unreachable_hint())?;
let status = response.status();
if status.is_success() || status == reqwest::StatusCode::CONFLICT {
Ok(())
} else {
bail!("创建 qBittorrent 分类「{name}」失败:HTTP {status}");
}
}
pub fn add_torrent(&self, filename: &str, bytes: Vec<u8>, category: &str) -> anyhow::Result<()> {
let part = reqwest::blocking::multipart::Part::bytes(bytes)
.file_name(filename.to_string())
.mime_str("application/x-bittorrent")
.context("构建种子上传")?;
let form = reqwest::blocking::multipart::Form::new()
.part("torrents", part)
.text("category", category.to_string())
.text("autoTMM", "true");
let response = self
.with_cookie(self.http.post(format!("{}/api/v2/torrents/add", self.endpoint)))
.multipart(form)
.send()
.with_context(|| self.unreachable_hint())?;
let status = response.status();
let body = response.text().unwrap_or_default();
if !status.is_success() || body.trim() == "Fails." {
let detail = if body.trim().is_empty() { format!("HTTP {status}") } else { body.trim().to_string() };
bail!("qBittorrent 拒绝了该种子:{detail}");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::QbtProfile;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
struct MockQbt {
endpoint: String,
requests: Arc<Mutex<Vec<String>>>,
}
fn serve_script(responses: Vec<(&'static str, &'static str, &'static str)>) -> MockQbt {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded = requests.clone();
std::thread::spawn(move || {
for (status, extra_headers, body) in responses {
let Ok((mut sock, _)) = listener.accept() else { return };
sock.set_read_timeout(Some(std::time::Duration::from_millis(200))).unwrap();
let mut raw = Vec::new();
let mut buf = [0u8; 8192];
loop {
match sock.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
raw.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&raw);
if let Some(headers_end) = text.find("\r\n\r\n") {
let content_length = text
.lines()
.find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)));
let body_len = raw.len() - (headers_end + 4);
match content_length {
Some(cl) if body_len >= cl => break,
None => break,
_ => {}
}
}
}
Err(_) => break,
}
}
recorded.lock().unwrap().push(String::from_utf8_lossy(&raw).into_owned());
let _ = write!(
sock,
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
body.len()
);
}
});
MockQbt { endpoint: format!("http://{addr}"), requests }
}
fn profile(endpoint: &str, username: &str, password: &str) -> QbtProfile {
QbtProfile {
endpoint: endpoint.to_string(),
username: username.to_string(),
password: password.to_string(),
}
}
#[test]
fn connect_with_auth_captures_sid_and_version() {
let mock = serve_script(vec![
("200 OK", "Set-Cookie: SID=abc123; path=/\r\n", "Ok."),
("200 OK", "", "v5.0.3"),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "admin", "pw")).unwrap();
assert_eq!(client.version(), "v5.0.3");
let requests = mock.requests.lock().unwrap();
assert!(requests[0].contains("POST /api/v2/auth/login"));
assert!(requests[0].contains("username=admin"));
assert!(requests[1].contains("GET /api/v2/app/version"));
assert!(requests[1].contains("SID=abc123"), "version request must carry the SID cookie");
}
#[test]
fn connect_captures_modern_qbt_sid_cookie() {
let mock = serve_script(vec![
("204 No Content", "Set-Cookie: QBT_SID_8080=EvMwGlN6; HttpOnly; SameSite=Lax; path=/\r\n", ""),
("200 OK", "", "v5.2.3.10"),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "admin", "pw")).unwrap();
assert_eq!(client.version(), "v5.2.3.10");
let requests = mock.requests.lock().unwrap();
assert!(requests[1].contains("GET /api/v2/app/version"));
assert!(
requests[1].contains("QBT_SID_8080=EvMwGlN6"),
"version request must carry the modern per-port session cookie"
);
}
#[test]
fn connect_bypass_mode_skips_login() {
let mock = serve_script(vec![("200 OK", "", "v5.0.3")]);
let client = QbtClient::connect(&profile(&mock.endpoint, "", "")).unwrap();
assert_eq!(client.version(), "v5.0.3");
let requests = mock.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].contains("GET /api/v2/app/version"));
}
#[test]
fn login_fails_body_is_friendly_error() {
let mock = serve_script(vec![("200 OK", "", "Fails.")]);
let err = QbtClient::connect(&profile(&mock.endpoint, "admin", "bad")).unwrap_err();
assert!(format!("{err:#}").contains("登录失败"), "was: {err:#}");
}
#[test]
fn login_401_is_friendly_error() {
let mock = serve_script(vec![("401 Unauthorized", "", "")]);
let err = QbtClient::connect(&profile(&mock.endpoint, "admin", "bad")).unwrap_err();
assert!(format!("{err:#}").contains("登录失败"), "was: {err:#}");
}
#[test]
fn login_ok_without_sid_cookie_uses_bypass() {
let mock = serve_script(vec![
("200 OK", "", "Ok."),
("200 OK", "", "v5.0.3"),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "admin", "pw")).unwrap();
assert_eq!(client.version(), "v5.0.3");
let requests = mock.requests.lock().unwrap();
assert!(requests[0].contains("POST /api/v2/auth/login"));
assert!(requests[1].contains("GET /api/v2/app/version"));
assert!(!requests[1].contains("SID="), "no cookie was issued, so none must be sent");
}
#[test]
fn authed_category_and_add_carry_sid_cookie() {
let mock = serve_script(vec![
("200 OK", "Set-Cookie: SID=xyz789; path=/\r\n", "Ok."),
("200 OK", "", "v5.0.3"),
("200 OK", "", ""),
("200 OK", "", "Ok."),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "admin", "pw")).unwrap();
client.ensure_category("mikan").unwrap();
client.add_torrent("x.torrent", b"d0:e".to_vec(), "mikan").unwrap();
let requests = mock.requests.lock().unwrap();
assert!(requests[2].contains("SID=xyz789"), "createCategory must carry the SID cookie");
assert!(requests[3].contains("SID=xyz789"), "torrents/add must carry the SID cookie");
assert!(requests[3].contains("application/x-bittorrent"));
let add_body = &requests[3];
let autotmm_pos = add_body.find("name=\"autoTMM\"").expect("autoTMM field present");
assert!(add_body[autotmm_pos..].contains("true"), "autoTMM must be true");
}
#[test]
fn ensure_category_accepts_ok_and_conflict() {
let mock = serve_script(vec![
("200 OK", "", "v5.0.3"),
("200 OK", "", ""),
("409 Conflict", "", ""),
("400 Bad Request", "", ""),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "", "")).unwrap();
client.ensure_category("mikan").unwrap();
client.ensure_category("mikan").unwrap();
assert!(client.ensure_category("").is_err());
let requests = mock.requests.lock().unwrap();
assert!(requests[1].contains("POST /api/v2/torrents/createCategory"));
assert!(requests[1].contains("category=mikan"));
}
#[test]
fn add_torrent_sends_multipart_and_accepts_ok() {
let mock = serve_script(vec![
("200 OK", "", "v5.0.3"),
("200 OK", "", "Ok."),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "", "")).unwrap();
client.add_torrent("ep [01].torrent", b"d8:announce0:e".to_vec(), "石纪元").unwrap();
let requests = mock.requests.lock().unwrap();
let add = &requests[1];
assert!(add.contains("POST /api/v2/torrents/add"));
assert!(add.contains("name=\"torrents\""));
assert!(add.contains("filename=\"ep [01].torrent\""));
assert!(add.contains("d8:announce0:e"));
assert!(add.contains("name=\"category\""));
assert!(add.contains("石纪元"));
assert!(add.contains("name=\"autoTMM\""));
}
#[test]
fn add_torrent_fails_body_is_error() {
let mock = serve_script(vec![
("200 OK", "", "v5.0.3"),
("200 OK", "", "Fails."),
]);
let client = QbtClient::connect(&profile(&mock.endpoint, "", "")).unwrap();
assert!(client.add_torrent("x.torrent", vec![1], "c").is_err());
}
}