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:
@@ -13,6 +13,7 @@ mod api_middleware;
|
|||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod prowlarr;
|
mod prowlarr;
|
||||||
|
mod qbittorrent;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -107,6 +108,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||||
.route("/prowlarr/test", get(prowlarr::test_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/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::delete_status_mapping,
|
crate::settings::delete_status_mapping,
|
||||||
crate::prowlarr::search_prowlarr,
|
crate::prowlarr::search_prowlarr,
|
||||||
crate::prowlarr::test_prowlarr,
|
crate::prowlarr::test_prowlarr,
|
||||||
|
crate::qbittorrent::add_torrent,
|
||||||
|
crate::qbittorrent::test_qbittorrent,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -124,6 +126,9 @@ use utoipa::OpenApi;
|
|||||||
crate::metadata::ExternalMetadataLinkDto,
|
crate::metadata::ExternalMetadataLinkDto,
|
||||||
crate::metadata::MissingBooksDto,
|
crate::metadata::MissingBooksDto,
|
||||||
crate::metadata::MissingBookItem,
|
crate::metadata::MissingBookItem,
|
||||||
|
crate::qbittorrent::QBittorrentAddRequest,
|
||||||
|
crate::qbittorrent::QBittorrentAddResponse,
|
||||||
|
crate::qbittorrent::QBittorrentTestResponse,
|
||||||
crate::prowlarr::ProwlarrSearchRequest,
|
crate::prowlarr::ProwlarrSearchRequest,
|
||||||
crate::prowlarr::ProwlarrRelease,
|
crate::prowlarr::ProwlarrRelease,
|
||||||
crate::prowlarr::ProwlarrCategory,
|
crate::prowlarr::ProwlarrCategory,
|
||||||
@@ -143,6 +148,7 @@ use utoipa::OpenApi;
|
|||||||
(name = "tokens", description = "API token management (Admin only)"),
|
(name = "tokens", description = "API token management (Admin only)"),
|
||||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||||
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||||
|
(name = "qbittorrent", description = "qBittorrent download client integration (Admin only)"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon)
|
modifiers(&SecurityAddon)
|
||||||
)]
|
)]
|
||||||
|
|||||||
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backoffice/app/api/qbittorrent/test/route.ts
Normal file
12
apps/backoffice/app/api/qbittorrent/test/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,13 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check if Prowlarr is configured on mount
|
// qBittorrent state
|
||||||
|
const [isQbConfigured, setIsQbConfigured] = useState(false);
|
||||||
|
const [sendingGuid, setSendingGuid] = useState<string | null>(null);
|
||||||
|
const [sentGuids, setSentGuids] = useState<Set<string>>(new Set());
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check if Prowlarr and qBittorrent are configured on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings/prowlarr")
|
fetch("/api/settings/prowlarr")
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.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()));
|
setIsConfigured(!!(data && data.api_key && data.api_key.trim()));
|
||||||
})
|
})
|
||||||
.catch(() => setIsConfigured(false));
|
.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) => {
|
const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => {
|
||||||
@@ -83,6 +95,30 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
setIsOpen(false);
|
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
|
// Don't render button if not configured
|
||||||
if (isConfigured === false) return null;
|
if (isConfigured === false) return null;
|
||||||
if (isConfigured === null) return null;
|
if (isConfigured === null) return null;
|
||||||
@@ -204,6 +240,31 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{isQbConfigured && release.downloadUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSendToQbittorrent(release.downloadUrl!, release.guid)}
|
||||||
|
disabled={sendingGuid === release.guid || sentGuids.has(release.guid)}
|
||||||
|
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 ${
|
||||||
|
sentGuids.has(release.guid)
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-primary hover:bg-primary/10"
|
||||||
|
}`}
|
||||||
|
title={sentGuids.has(release.guid) ? t("prowlarr.sentSuccess") : t("prowlarr.sendToQbittorrent")}
|
||||||
|
>
|
||||||
|
{sendingGuid === release.guid ? (
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||||
|
) : sentGuids.has(release.guid) ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 8l4 4 6-7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 8V14H2V2H8M10 2H14V6M14 2L7 9" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{release.downloadUrl && (
|
{release.downloadUrl && (
|
||||||
<a
|
<a
|
||||||
href={release.downloadUrl}
|
href={release.downloadUrl}
|
||||||
@@ -238,6 +299,13 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* qBittorrent send error */}
|
||||||
|
{sendError && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No results */}
|
{/* No results */}
|
||||||
{!isSearching && !error && query && results.length === 0 && (
|
{!isSearching && !error && query && results.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
|
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
|
||||||
|
|||||||
@@ -583,6 +583,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
{/* Prowlarr */}
|
{/* Prowlarr */}
|
||||||
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
|
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
|
||||||
|
{/* qBittorrent */}
|
||||||
|
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
|
||||||
{/* Komga Sync */}
|
{/* Komga Sync */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1353,3 +1356,127 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// qBittorrent sub-component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||||
|
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 (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="settings" size="md" />
|
||||||
|
{t("settings.qbittorrent")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("settings.qbittorrentDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentUrl")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="url"
|
||||||
|
placeholder={t("settings.qbittorrentUrlPlaceholder")}
|
||||||
|
value={qbUrl}
|
||||||
|
onChange={(e) => setQbUrl(e.target.value)}
|
||||||
|
onBlur={() => saveQbittorrent()}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentUsername")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="text"
|
||||||
|
value={qbUsername}
|
||||||
|
onChange={(e) => setQbUsername(e.target.value)}
|
||||||
|
onBlur={() => saveQbittorrent()}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="password"
|
||||||
|
value={qbPassword}
|
||||||
|
onChange={(e) => setQbPassword(e.target.value)}
|
||||||
|
onBlur={() => saveQbittorrent()}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting || !qbUrl || !qbUsername}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<>
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||||
|
{t("settings.testing")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="refresh" size="sm" className="mr-2" />
|
||||||
|
{t("settings.testConnection")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{testResult && (
|
||||||
|
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
|
||||||
|
{testResult.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -910,3 +910,18 @@ export type ProwlarrTestResponse = {
|
|||||||
message: string;
|
message: string;
|
||||||
indexer_count: number | null;
|
indexer_count: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// qBittorrent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type QBittorrentAddResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QBittorrentTestResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
version: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -491,6 +491,18 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"prowlarr.notConfigured": "Prowlarr is not configured",
|
"prowlarr.notConfigured": "Prowlarr is not configured",
|
||||||
"prowlarr.download": "Download",
|
"prowlarr.download": "Download",
|
||||||
"prowlarr.info": "Info",
|
"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
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
|
|||||||
@@ -489,6 +489,18 @@ const fr = {
|
|||||||
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
|
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
|
||||||
"prowlarr.download": "Télécharger",
|
"prowlarr.download": "Télécharger",
|
||||||
"prowlarr.info": "Info",
|
"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
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
|
|||||||
3
infra/migrations/0044_add_qbittorrent_settings.sql
Normal file
3
infra/migrations/0044_add_qbittorrent_settings.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('qbittorrent', '{"url": "", "username": "", "password": ""}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
Reference in New Issue
Block a user