feat: add torrent client interface with qbittorrent support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TorrentClientError {
|
||||
#[error("authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("torrent not found: {0}")]
|
||||
TorrentNotFound(String),
|
||||
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("unexpected error: {0}")]
|
||||
Unexpected(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TorrentState {
|
||||
Downloading,
|
||||
Seeding,
|
||||
Paused,
|
||||
Queued,
|
||||
Checking,
|
||||
Error,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TorrentInfo {
|
||||
pub hash: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub progress: f64,
|
||||
pub download_speed: u64,
|
||||
pub upload_speed: u64,
|
||||
pub state: TorrentState,
|
||||
pub save_path: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TorrentClient: Send + Sync {
|
||||
async fn connect(&mut self) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError>;
|
||||
|
||||
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
|
||||
|
||||
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod client;
|
||||
mod qbittorrent;
|
||||
|
||||
pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
|
||||
pub use qbittorrent::QBittorrentClient;
|
||||
@@ -0,0 +1,250 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{multipart, Client};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use url::Url;
|
||||
|
||||
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
|
||||
|
||||
pub struct QBittorrentClient {
|
||||
base_url: Url,
|
||||
username: String,
|
||||
password: String,
|
||||
http: Client,
|
||||
connected: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QBTorrent {
|
||||
hash: String,
|
||||
name: String,
|
||||
size: i64,
|
||||
progress: f64,
|
||||
dlspeed: i64,
|
||||
upspeed: i64,
|
||||
state: String,
|
||||
save_path: String,
|
||||
}
|
||||
|
||||
impl QBittorrentClient {
|
||||
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
|
||||
let base_url = Url::parse(base_url)
|
||||
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
|
||||
|
||||
let http = Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
http,
|
||||
connected: Arc::new(RwLock::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
format!("{}api/v2{}", self.base_url, path)
|
||||
}
|
||||
|
||||
fn map_state(state: &str) -> TorrentState {
|
||||
match state {
|
||||
"downloading" | "forcedDL" | "metaDL" | "allocating" => TorrentState::Downloading,
|
||||
"uploading" | "forcedUP" | "stalledUP" => TorrentState::Seeding,
|
||||
"pausedDL" | "pausedUP" => TorrentState::Paused,
|
||||
"queuedDL" | "queuedUP" => TorrentState::Queued,
|
||||
"checkingDL" | "checkingUP" | "checkingResumeData" => TorrentState::Checking,
|
||||
"error" | "missingFiles" => TorrentState::Error,
|
||||
_ => TorrentState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_torrent(t: QBTorrent) -> TorrentInfo {
|
||||
TorrentInfo {
|
||||
hash: t.hash,
|
||||
name: t.name,
|
||||
size: t.size.max(0) as u64,
|
||||
progress: t.progress,
|
||||
download_speed: t.dlspeed.max(0) as u64,
|
||||
upload_speed: t.upspeed.max(0) as u64,
|
||||
state: Self::map_state(&t.state),
|
||||
save_path: t.save_path,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_connected(&self) -> Result<(), TorrentClientError> {
|
||||
let connected = *self.connected.read().await;
|
||||
if !connected {
|
||||
return Err(TorrentClientError::ConnectionFailed("not connected".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TorrentClient for QBittorrentClient {
|
||||
async fn connect(&mut self) -> Result<(), TorrentClientError> {
|
||||
let params = [
|
||||
("username", self.username.as_str()),
|
||||
("password", self.password.as_str()),
|
||||
];
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/auth/login"))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let text = resp.text().await?;
|
||||
|
||||
if text == "Ok." {
|
||||
*self.connected.write().await = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::AuthenticationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
|
||||
self.http
|
||||
.post(self.api_url("/auth/logout"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
*self.connected.write().await = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.api_url("/torrents/info"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let torrents: Vec<QBTorrent> = resp.json().await?;
|
||||
Ok(torrents.into_iter().map(Self::map_torrent).collect())
|
||||
}
|
||||
|
||||
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.api_url("/torrents/info"))
|
||||
.query(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let torrents: Vec<QBTorrent> = resp.json().await?;
|
||||
torrents
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(Self::map_torrent)
|
||||
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let mut form = multipart::Form::new().text("urls", url.to_string());
|
||||
|
||||
if let Some(path) = save_path {
|
||||
form = form.text("savepath", path.to_string());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/add"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::InvalidRequest(
|
||||
resp.text().await.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let part = multipart::Part::bytes(torrent_data.to_vec())
|
||||
.file_name("torrent.torrent")
|
||||
.mime_str("application/x-bittorrent")
|
||||
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
|
||||
|
||||
let mut form = multipart::Form::new().part("torrents", part);
|
||||
|
||||
if let Some(path) = save_path {
|
||||
form = form.text("savepath", path.to_string());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/add"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::InvalidRequest(
|
||||
resp.text().await.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/delete"))
|
||||
.form(&[
|
||||
("hashes", hash),
|
||||
("deleteFiles", if delete_files { "true" } else { "false" }),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
self.http
|
||||
.post(self.api_url("/torrents/pause"))
|
||||
.form(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
self.http
|
||||
.post(self.api_url("/torrents/resume"))
|
||||
.form(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user