"use client"; import { useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Icon } from "./ui"; import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api"; import { useTranslation } from "../../lib/i18n/context"; interface MissingBookItem { title: string | null; volume_number: number | null; external_book_id: string | null; } interface ProwlarrSearchModalProps { seriesName: string; missingBooks: MissingBookItem[] | null; } function formatSize(bytes: number): string { if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB"; if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB"; if (bytes >= 1024) return (bytes / 1024).toFixed(0) + " KB"; return bytes + " B"; } export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearchModalProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [isConfigured, setIsConfigured] = useState(null); const [isSearching, setIsSearching] = useState(false); const [results, setResults] = useState([]); const [query, setQuery] = useState(""); const [error, setError] = useState(null); // 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)) .then((data) => { 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 [searchInput, setSearchInput] = useState(`"${seriesName}"`); const doSearch = useCallback(async (queryOverride?: string) => { const searchQuery = queryOverride ?? searchInput; if (!searchQuery.trim()) return; setIsSearching(true); setError(null); setResults([]); try { const missing_volumes = missingBooks?.map((b) => ({ volume_number: b.volume_number, title: b.title, })) ?? undefined; const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes }; const resp = await fetch("/api/prowlarr/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await resp.json(); if (data.error) { setError(data.error); } else { const searchResp = data as ProwlarrSearchResponse; setResults(searchResp.results); setQuery(searchResp.query); } } catch { setError(t("prowlarr.searchError")); } finally { setIsSearching(false); } }, [t, seriesName, searchInput]); const defaultQuery = `"${seriesName}"`; function handleOpen() { setIsOpen(true); setResults([]); setError(null); setQuery(""); setSearchInput(defaultQuery); // Auto-search the series on open doSearch(defaultQuery); } function handleClose() { 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; const modal = isOpen ? createPortal( <>
{/* Header */}

{t("prowlarr.modalTitle")}

{/* Search input */}
{ e.preventDefault(); if (searchInput.trim()) doSearch(searchInput.trim()); }} className="flex items-center gap-2" > setSearchInput(e.target.value)} className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" placeholder={t("prowlarr.searchPlaceholder")} />
{/* Quick search badges */}
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => { const label = book.title || `Vol. ${book.volume_number}`; const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`; return ( ); })}
{/* Error */} {error && (
{error}
)} {/* Searching indicator */} {isSearching && (
{t("prowlarr.searching")}
)} {/* Results */} {!isSearching && results.length > 0 && (

{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })} {query && ({query})}

{results.map((release, i) => { const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0; return ( ); })}
{t("prowlarr.columnTitle")} {t("prowlarr.columnIndexer")} {t("prowlarr.columnSize")} {t("prowlarr.columnSeeders")} {t("prowlarr.columnLeechers")} {t("prowlarr.columnProtocol")}
{release.title} {hasMissing && (
{release.matchedMissingVolumes!.map((vol) => ( {t("prowlarr.missingVol", { vol })} ))}
)}
{release.indexer || "—"} {release.size > 0 ? formatSize(release.size) : "—"} {release.seeders != null ? ( 0 ? "text-green-500 font-medium" : "text-muted-foreground"}> {release.seeders} ) : "—"} {release.leechers != null ? release.leechers : "—"} {release.protocol && ( {release.protocol} )}
{isQbConfigured && release.downloadUrl && ( )} {release.downloadUrl && ( )} {release.infoUrl && ( )}
)} {/* qBittorrent send error */} {sendError && (
{sendError}
)} {/* No results */} {!isSearching && !error && query && results.length === 0 && (

{t("prowlarr.noResults")}

)}
, document.body, ) : null; return ( <> {modal} ); }