feat: add qBittorrent download button to download detection report
Show a download button on each available release in the detection report when qBittorrent is configured, matching the Prowlarr search modal behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
} from "@/app/components/ui";
|
} from "@/app/components/ui";
|
||||||
import { JobDetailLive } from "@/app/components/JobDetailLive";
|
import { JobDetailLive } from "@/app/components/JobDetailLive";
|
||||||
|
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
@@ -984,6 +985,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
|
|
||||||
{/* Download detection — available releases per series */}
|
{/* Download detection — available releases per series */}
|
||||||
{isDownloadDetection && downloadDetectionResults.length > 0 && (
|
{isDownloadDetection && downloadDetectionResults.length > 0 && (
|
||||||
|
<QbittorrentProvider>
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("jobDetail.downloadAvailableReleases")}</CardTitle>
|
<CardTitle>{t("jobDetail.downloadAvailableReleases")}</CardTitle>
|
||||||
@@ -1010,7 +1012,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{r.available_releases && r.available_releases.length > 0 && (
|
{r.available_releases && r.available_releases.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{r.available_releases.map((release, idx) => (
|
{r.available_releases.map((release, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-2 p-2 rounded bg-background/60 border border-border/40">
|
<div key={idx} className="flex items-center gap-2 p-2 rounded bg-background/60 border border-border/40">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p>
|
<p className="text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p>
|
||||||
<div className="flex items-center gap-3 mt-1 flex-wrap">
|
<div className="flex items-center gap-3 mt-1 flex-wrap">
|
||||||
@@ -1032,6 +1034,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{release.download_url && (
|
||||||
|
<QbittorrentDownloadButton downloadUrl={release.download_url} releaseId={`${r.id}-${idx}`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1040,6 +1045,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</QbittorrentProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata batch results */}
|
{/* Metadata batch results */}
|
||||||
|
|||||||
82
apps/backoffice/app/components/QbittorrentDownloadButton.tsx
Normal file
82
apps/backoffice/app/components/QbittorrentDownloadButton.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, createContext, useContext, type ReactNode } from "react";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
const QbConfigContext = createContext(false);
|
||||||
|
|
||||||
|
export function QbittorrentProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [configured, setConfigured] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/qbittorrent")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
setConfigured(!!(data && data.url && data.url.trim() && data.username && data.username.trim()));
|
||||||
|
})
|
||||||
|
.catch(() => setConfigured(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <QbConfigContext.Provider value={configured}>{children}</QbConfigContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QbittorrentDownloadButton({ downloadUrl, releaseId }: { downloadUrl: string; releaseId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const configured = useContext(QbConfigContext);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!configured) return null;
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
setSending(true);
|
||||||
|
setError(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) {
|
||||||
|
setError(data.error);
|
||||||
|
} else if (data.success) {
|
||||||
|
setSent(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || t("prowlarr.sentError"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("prowlarr.sentError"));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || sent}
|
||||||
|
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 ${
|
||||||
|
sent
|
||||||
|
? "text-green-500"
|
||||||
|
: error
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-primary hover:bg-primary/10"
|
||||||
|
}`}
|
||||||
|
title={sent ? t("prowlarr.sentSuccess") : error || t("prowlarr.sendToQbittorrent")}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||||
|
) : sent ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<Icon name="download" size="sm" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user