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