feat: add qBittorrent download client integration
Send Prowlarr search results directly to qBittorrent from the modal. Backend authenticates via SID cookie (login + add torrent endpoints). - Backend: qbittorrent module with add and test endpoints - Migration: add qbittorrent settings (url, username, password) - Settings UI: qBittorrent config card with test connection - ProwlarrSearchModal: send-to-qBittorrent button per result row with spinner/checkmark state progression - Button only shown when qBittorrent is configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
218
apps/api/src/qbittorrent.rs
Normal file
218
apps/api/src/qbittorrent.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct QBittorrentAddRequest {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct QBittorrentAddResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct QBittorrentTestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QBittorrentConfig {
|
||||
url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn load_qbittorrent_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<(String, String, String), ApiError> {
|
||||
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'qbittorrent'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::bad_request("qBittorrent is not configured"))?;
|
||||
let value: serde_json::Value = row.get("value");
|
||||
let config: QBittorrentConfig = serde_json::from_value(value)
|
||||
.map_err(|e| ApiError::internal(format!("invalid qbittorrent config: {e}")))?;
|
||||
|
||||
if config.url.is_empty() || config.username.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"qBittorrent URL and username must be configured in settings",
|
||||
));
|
||||
}
|
||||
|
||||
let url = config.url.trim_end_matches('/').to_string();
|
||||
Ok((url, config.username, config.password))
|
||||
}
|
||||
|
||||
// ─── Login helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn qbittorrent_login(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let resp = client
|
||||
.post(format!("{base_url}/api/v2/auth/login"))
|
||||
.form(&[("username", username), ("password", password)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("qBittorrent login request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::internal(format!(
|
||||
"qBittorrent login failed ({status}): {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract SID from Set-Cookie header
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let sid = cookie_header
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("SID="))
|
||||
.ok_or_else(|| ApiError::internal("Failed to get SID cookie from qBittorrent"))?
|
||||
.to_string();
|
||||
|
||||
Ok(sid)
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Add a torrent to qBittorrent
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/qbittorrent/add",
|
||||
tag = "qbittorrent",
|
||||
request_body = QBittorrentAddRequest,
|
||||
responses(
|
||||
(status = 200, body = QBittorrentAddResponse),
|
||||
(status = 400, description = "Bad request or qBittorrent not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "qBittorrent connection error"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn add_torrent(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<QBittorrentAddRequest>,
|
||||
) -> Result<Json<QBittorrentAddResponse>, ApiError> {
|
||||
if body.url.is_empty() {
|
||||
return Err(ApiError::bad_request("url is required"));
|
||||
}
|
||||
|
||||
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let sid = qbittorrent_login(&client, &base_url, &username, &password).await?;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{base_url}/api/v2/torrents/add"))
|
||||
.header("Cookie", format!("SID={sid}"))
|
||||
.form(&[("urls", &body.url)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(Json(QBittorrentAddResponse {
|
||||
success: true,
|
||||
message: "Torrent added to qBittorrent".to_string(),
|
||||
}))
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentAddResponse {
|
||||
success: false,
|
||||
message: format!("qBittorrent returned {status}: {text}"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to qBittorrent
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/qbittorrent/test",
|
||||
tag = "qbittorrent",
|
||||
responses(
|
||||
(status = 200, body = QBittorrentTestResponse),
|
||||
(status = 400, description = "qBittorrent not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn test_qbittorrent(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<QBittorrentTestResponse>, ApiError> {
|
||||
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let sid = match qbittorrent_login(&client, &base_url, &username, &password).await {
|
||||
Ok(sid) => sid,
|
||||
Err(e) => {
|
||||
return Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("Login failed: {}", e.message),
|
||||
version: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/v2/app/version"))
|
||||
.header("Cookie", format!("SID={sid}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let version = r.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentTestResponse {
|
||||
success: true,
|
||||
message: format!("Connected successfully ({})", version.trim()),
|
||||
version: Some(version.trim().to_string()),
|
||||
}))
|
||||
}
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("qBittorrent returned {status}: {text}"),
|
||||
version: None,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("Connection failed: {e}"),
|
||||
version: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user