Files
stripstream-librarian/apps/backoffice/app/components/ProwlarrSearchModal.tsx
Froidefond Julien 504185f31f feat: add editable search input to Prowlarr modal with scrollable badges
- Add text input for custom search queries in Prowlarr modal
- Quick search badges pre-fill the input and trigger search
- Default query uses quoted series name for exact match
- Add custom_query support to backend API
- Limit badge area height with vertical scroll
- Add debug logging for Prowlarr API responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:33:40 +01:00

368 lines
17 KiB
TypeScript

"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<boolean | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<ProwlarrRelease[]>([]);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
// 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))
.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 body = { series_name: seriesName, custom_query: searchQuery.trim() };
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(
<>
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={handleClose}
/>
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">{t("prowlarr.modalTitle")}</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="p-5 space-y-4">
{/* Search input */}
<form
onSubmit={(e) => {
e.preventDefault();
if (searchInput.trim()) doSearch(searchInput.trim());
}}
className="flex items-center gap-2"
>
<input
type="text"
value={searchInput}
onChange={(e) => 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")}
/>
<button
type="submit"
disabled={isSearching || !searchInput.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Icon name="search" size="sm" />
{t("prowlarr.searchAction")}
</button>
</form>
{/* Quick search badges */}
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
<button
type="button"
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
>
{seriesName}
</button>
{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 (
<button
key={i}
type="button"
onClick={() => { setSearchInput(q); doSearch(q); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
{label}
</button>
);
})}
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Searching indicator */}
{isSearching && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Icon name="spinner" size="sm" className="animate-spin" />
{t("prowlarr.searching")}
</div>
)}
{/* Results */}
{!isSearching && results.length > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-3">
{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })}
{query && <span className="ml-1 text-xs opacity-70">({query})</span>}
</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-left">
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnTitle")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnIndexer")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-right">{t("prowlarr.columnSize")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnSeeders")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnLeechers")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnProtocol")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{results.map((release, i) => (
<tr key={release.guid || i} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 max-w-[400px]">
<span className="truncate block" title={release.title}>
{release.title}
</span>
</td>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{release.indexer || "—"}
</td>
<td className="px-3 py-2 text-right text-muted-foreground whitespace-nowrap">
{release.size > 0 ? formatSize(release.size) : "—"}
</td>
<td className="px-3 py-2 text-center">
{release.seeders != null ? (
<span className={release.seeders > 0 ? "text-green-500 font-medium" : "text-muted-foreground"}>
{release.seeders}
</span>
) : "—"}
</td>
<td className="px-3 py-2 text-center text-muted-foreground">
{release.leechers != null ? release.leechers : "—"}
</td>
<td className="px-3 py-2">
{release.protocol && (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
release.protocol === "torrent"
? "bg-blue-500/15 text-blue-600"
: "bg-amber-500/15 text-amber-600"
}`}>
{release.protocol}
</span>
)}
</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}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-primary hover:bg-primary/10 transition-colors"
title={t("prowlarr.download")}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4 7l4 4 4-4M2 13h12" />
</svg>
</a>
)}
{release.infoUrl && (
<a
href={release.infoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:bg-muted/50 transition-colors"
title={t("prowlarr.info")}
>
<Icon name="externalLink" size="sm" />
</a>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</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>
)}
</div>
</div>
</div>
</>,
document.body,
)
: null;
return (
<>
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
>
<Icon name="search" size="sm" />
{t("prowlarr.searchButton")}
</button>
{modal}
</>
);
}