diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 416307e..2a1feae 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -13,6 +13,7 @@ mod api_middleware; mod openapi; mod pages; mod prowlarr; +mod qbittorrent; mod reading_progress; mod search; mod settings; @@ -107,6 +108,8 @@ async fn main() -> anyhow::Result<()> { .route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token)) .route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr)) .route("/prowlarr/test", get(prowlarr::test_prowlarr)) + .route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent)) + .route("/qbittorrent/test", get(qbittorrent::test_qbittorrent)) .route("/komga/sync", axum::routing::post(komga::sync_komga_read_books)) .route("/komga/reports", get(komga::list_sync_reports)) .route("/komga/reports/:id", get(komga::get_sync_report)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 26fd1f4..7979876 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -60,6 +60,8 @@ use utoipa::OpenApi; crate::settings::delete_status_mapping, crate::prowlarr::search_prowlarr, crate::prowlarr::test_prowlarr, + crate::qbittorrent::add_torrent, + crate::qbittorrent::test_qbittorrent, ), components( schemas( @@ -124,6 +126,9 @@ use utoipa::OpenApi; crate::metadata::ExternalMetadataLinkDto, crate::metadata::MissingBooksDto, crate::metadata::MissingBookItem, + crate::qbittorrent::QBittorrentAddRequest, + crate::qbittorrent::QBittorrentAddResponse, + crate::qbittorrent::QBittorrentTestResponse, crate::prowlarr::ProwlarrSearchRequest, crate::prowlarr::ProwlarrRelease, crate::prowlarr::ProwlarrCategory, @@ -143,6 +148,7 @@ use utoipa::OpenApi; (name = "tokens", description = "API token management (Admin only)"), (name = "settings", description = "Application settings and cache management (Admin only)"), (name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"), + (name = "qbittorrent", description = "qBittorrent download client integration (Admin only)"), ), modifiers(&SecurityAddon) )] diff --git a/apps/api/src/qbittorrent.rs b/apps/api/src/qbittorrent.rs new file mode 100644 index 0000000..d942182 --- /dev/null +++ b/apps/api/src/qbittorrent.rs @@ -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, +} + +// ─── 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 { + 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, + Json(body): Json, +) -> Result, 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, +) -> Result, 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, + })), + } +} diff --git a/apps/backoffice/app/api/qbittorrent/add/route.ts b/apps/backoffice/app/api/qbittorrent/add/route.ts new file mode 100644 index 0000000..e993438 --- /dev/null +++ b/apps/backoffice/app/api/qbittorrent/add/route.ts @@ -0,0 +1,16 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const data = await apiFetch("/qbittorrent/add", { + method: "POST", + body: JSON.stringify(body), + }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to add torrent"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/qbittorrent/test/route.ts b/apps/backoffice/app/api/qbittorrent/test/route.ts new file mode 100644 index 0000000..5233d33 --- /dev/null +++ b/apps/backoffice/app/api/qbittorrent/test/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function GET() { + try { + const data = await apiFetch("/qbittorrent/test"); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to test qBittorrent"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/components/ProwlarrSearchModal.tsx b/apps/backoffice/app/components/ProwlarrSearchModal.tsx index 273ea16..f6671ab 100644 --- a/apps/backoffice/app/components/ProwlarrSearchModal.tsx +++ b/apps/backoffice/app/components/ProwlarrSearchModal.tsx @@ -33,7 +33,13 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch const [query, setQuery] = useState(""); const [error, setError] = useState(null); - // Check if Prowlarr is configured on mount + // qBittorrent state + const [isQbConfigured, setIsQbConfigured] = useState(false); + const [sendingGuid, setSendingGuid] = useState(null); + const [sentGuids, setSentGuids] = useState>(new Set()); + const [sendError, setSendError] = useState(null); + + // Check if Prowlarr and qBittorrent are configured on mount useEffect(() => { fetch("/api/settings/prowlarr") .then((r) => (r.ok ? r.json() : null)) @@ -41,6 +47,12 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch setIsConfigured(!!(data && data.api_key && data.api_key.trim())); }) .catch(() => setIsConfigured(false)); + fetch("/api/settings/qbittorrent") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + setIsQbConfigured(!!(data && data.url && data.url.trim() && data.username && data.username.trim())); + }) + .catch(() => setIsQbConfigured(false)); }, []); const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => { @@ -83,6 +95,30 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch setIsOpen(false); } + async function handleSendToQbittorrent(downloadUrl: string, guid: string) { + setSendingGuid(guid); + setSendError(null); + try { + const resp = await fetch("/api/qbittorrent/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: downloadUrl }), + }); + const data = await resp.json(); + if (data.error) { + setSendError(data.error); + } else if (data.success) { + setSentGuids((prev) => new Set(prev).add(guid)); + } else { + setSendError(data.message || t("prowlarr.sentError")); + } + } catch { + setSendError(t("prowlarr.sentError")); + } finally { + setSendingGuid(null); + } + } + // Don't render button if not configured if (isConfigured === false) return null; if (isConfigured === null) return null; @@ -204,6 +240,31 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
+ {isQbConfigured && release.downloadUrl && ( + + )} {release.downloadUrl && ( )} + {/* qBittorrent send error */} + {sendError && ( +
+ {sendError} +
+ )} + {/* No results */} {!isSearching && !error && query && results.length === 0 && (

{t("prowlarr.noResults")}

diff --git a/apps/backoffice/app/settings/SettingsPage.tsx b/apps/backoffice/app/settings/SettingsPage.tsx index 68a01fb..f2f9fac 100644 --- a/apps/backoffice/app/settings/SettingsPage.tsx +++ b/apps/backoffice/app/settings/SettingsPage.tsx @@ -583,6 +583,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi {/* Prowlarr */} + {/* qBittorrent */} + + {/* Komga Sync */} @@ -1353,3 +1356,127 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri ); } + +// --------------------------------------------------------------------------- +// qBittorrent sub-component +// --------------------------------------------------------------------------- + +function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + const { t } = useTranslation(); + const [qbUrl, setQbUrl] = useState(""); + const [qbUsername, setQbUsername] = useState(""); + const [qbPassword, setQbPassword] = useState(""); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + useEffect(() => { + fetch("/api/settings/qbittorrent") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + if (data.url) setQbUrl(data.url); + if (data.username) setQbUsername(data.username); + if (data.password) setQbPassword(data.password); + } + }) + .catch(() => {}); + }, []); + + function saveQbittorrent() { + handleUpdateSetting("qbittorrent", { + url: qbUrl, + username: qbUsername, + password: qbPassword, + }); + } + + async function handleTestConnection() { + setIsTesting(true); + setTestResult(null); + try { + const resp = await fetch("/api/qbittorrent/test"); + const data = await resp.json(); + if (data.error) { + setTestResult({ success: false, message: data.error }); + } else { + setTestResult(data); + } + } catch { + setTestResult({ success: false, message: "Failed to connect" }); + } finally { + setIsTesting(false); + } + } + + return ( + + + + + {t("settings.qbittorrent")} + + {t("settings.qbittorrentDesc")} + + +
+ + + + setQbUrl(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + + + + + + setQbUsername(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + + + + setQbPassword(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + + + +
+ + {testResult && ( + + {testResult.message} + + )} +
+
+
+
+ ); +} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index a1b1e74..a90b06a 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -910,3 +910,18 @@ export type ProwlarrTestResponse = { message: string; indexer_count: number | null; }; + +// --------------------------------------------------------------------------- +// qBittorrent +// --------------------------------------------------------------------------- + +export type QBittorrentAddResponse = { + success: boolean; + message: string; +}; + +export type QBittorrentTestResponse = { + success: boolean; + message: string; + version: string | null; +}; diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index ba9e861..f2f052d 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -491,6 +491,18 @@ const en: Record = { "prowlarr.notConfigured": "Prowlarr is not configured", "prowlarr.download": "Download", "prowlarr.info": "Info", + "prowlarr.sendToQbittorrent": "Send to qBittorrent", + "prowlarr.sending": "Sending...", + "prowlarr.sentSuccess": "Sent to qBittorrent", + "prowlarr.sentError": "Failed to send to qBittorrent", + + // Settings - qBittorrent + "settings.qbittorrent": "qBittorrent", + "settings.qbittorrentDesc": "Configure qBittorrent as a download client. Torrents found via Prowlarr can be sent directly to qBittorrent.", + "settings.qbittorrentUrl": "qBittorrent URL", + "settings.qbittorrentUrlPlaceholder": "http://localhost:8080", + "settings.qbittorrentUsername": "Username", + "settings.qbittorrentPassword": "Password", // Settings - Language "settings.language": "Language", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 789590c..83b5401 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -489,6 +489,18 @@ const fr = { "prowlarr.notConfigured": "Prowlarr n'est pas configuré", "prowlarr.download": "Télécharger", "prowlarr.info": "Info", + "prowlarr.sendToQbittorrent": "Envoyer à qBittorrent", + "prowlarr.sending": "Envoi...", + "prowlarr.sentSuccess": "Envoyé à qBittorrent", + "prowlarr.sentError": "Échec de l'envoi à qBittorrent", + + // Settings - qBittorrent + "settings.qbittorrent": "qBittorrent", + "settings.qbittorrentDesc": "Configurer qBittorrent comme client de téléchargement. Les torrents trouvés via Prowlarr peuvent être envoyés directement à qBittorrent.", + "settings.qbittorrentUrl": "URL qBittorrent", + "settings.qbittorrentUrlPlaceholder": "http://localhost:8080", + "settings.qbittorrentUsername": "Nom d'utilisateur", + "settings.qbittorrentPassword": "Mot de passe", // Settings - Language "settings.language": "Langue", diff --git a/infra/migrations/0044_add_qbittorrent_settings.sql b/infra/migrations/0044_add_qbittorrent_settings.sql new file mode 100644 index 0000000..e16edec --- /dev/null +++ b/infra/migrations/0044_add_qbittorrent_settings.sql @@ -0,0 +1,3 @@ +INSERT INTO app_settings (key, value) VALUES + ('qbittorrent', '{"url": "", "username": "", "password": ""}') +ON CONFLICT DO NOTHING;