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:
2026-03-19 21:51:28 +01:00
parent 57bc82703d
commit c04d4fb618
11 changed files with 493 additions and 1 deletions

View 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 });
}
}

View 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 });
}
}

View File

@@ -33,7 +33,13 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
const [query, setQuery] = useState("");
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(() => {
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
</td>
<td className="px-3 py-2">
<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 && (
<a
href={release.downloadUrl}
@@ -238,6 +299,13 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
</div>
)}
{/* qBittorrent send error */}
{sendError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{sendError}
</div>
)}
{/* No results */}
{!isSearching && !error && query && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>

View File

@@ -583,6 +583,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{/* Prowlarr */}
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
{/* qBittorrent */}
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -1353,3 +1356,127 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
</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>
);
}

View File

@@ -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;
};

View File

@@ -491,6 +491,18 @@ const en: Record<TranslationKey, string> = {
"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",

View File

@@ -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",