feat: add Prowlarr integration for manual release search

Add Prowlarr indexer integration (step 1: config + manual search).
Allows searching for comics/ebooks releases on Prowlarr indexers
directly from the series detail page, with download links and
per-volume search for missing books.

- Backend: new prowlarr module with search and test endpoints
- Migration: add prowlarr settings (url, api_key, categories)
- Settings UI: Prowlarr config card with test connection button
- ProwlarrSearchModal: auto-search on open, missing volumes shortcuts
- Fix series.readCount i18n plural parameter on series pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:43:34 +01:00
parent e6aa7ebed0
commit 57bc82703d
13 changed files with 755 additions and 2 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("/prowlarr/search", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to search Prowlarr";
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("/prowlarr/test");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to test Prowlarr connection";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,266 @@
"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);
// Check if Prowlarr is 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));
}, []);
const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => {
setIsSearching(true);
setError(null);
setResults([]);
try {
const body: { series_name: string; volume_number?: number } = { series_name: searchSeriesName };
if (volumeNumber !== undefined) body.volume_number = volumeNumber;
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]);
function handleOpen() {
setIsOpen(true);
setResults([]);
setError(null);
setQuery("");
// Auto-search the series on open
doSearch(seriesName);
}
function handleClose() {
setIsOpen(false);
}
// 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">
{/* Missing volumes + re-search */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => doSearch(seriesName)}
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"
>
<Icon name="search" size="sm" />
{seriesName}
</button>
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => (
<button
key={i}
type="button"
onClick={() => doSearch(seriesName, book.volume_number ?? undefined)}
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"
>
<Icon name="search" size="sm" />
{book.title || `Vol. ${book.volume_number}`}
</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">
{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>
)}
{/* 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}
</>
);
}

View File

@@ -4,6 +4,7 @@ import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButto
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
import { OffsetPagination } from "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import Image from "next/image";
@@ -138,7 +139,7 @@ export default async function SeriesDetailPage({
</span>
<span className="w-px h-4 bg-border" />
<span className="text-muted-foreground">
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total) })}
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
</span>
{/* Progress bar */}
@@ -177,6 +178,10 @@ export default async function SeriesDetailPage({
existingLink={existingLink}
initialMissing={missingData}
/>
<ProwlarrSearchModal
seriesName={seriesName}
missingBooks={missingData?.missing_books ?? null}
/>
</div>
</div>
</div>

View File

@@ -95,7 +95,7 @@ export default async function LibrarySeriesPage({
</h3>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-muted-foreground">
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count) })}
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
<MarkSeriesReadButton
seriesName={s.name}

View File

@@ -580,6 +580,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{/* Status Mappings */}
<StatusMappingsCard />
{/* Prowlarr */}
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -1217,3 +1220,136 @@ function StatusMappingsCard() {
</Card>
);
}
// ---------------------------------------------------------------------------
// Prowlarr sub-component
// ---------------------------------------------------------------------------
function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
const { t } = useTranslation();
const [prowlarrUrl, setProwlarrUrl] = useState("");
const [prowlarrApiKey, setProwlarrApiKey] = useState("");
const [prowlarrCategories, setProwlarrCategories] = useState("7030, 7020");
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
useEffect(() => {
fetch("/api/settings/prowlarr")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
if (data.url) setProwlarrUrl(data.url);
if (data.api_key) setProwlarrApiKey(data.api_key);
if (data.categories) setProwlarrCategories(data.categories.join(", "));
}
})
.catch(() => {});
}, []);
function saveProwlarr(url?: string, apiKey?: string, cats?: string) {
const categories = (cats ?? prowlarrCategories)
.split(",")
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n));
handleUpdateSetting("prowlarr", {
url: url ?? prowlarrUrl,
api_key: apiKey ?? prowlarrApiKey,
categories,
});
}
async function handleTestConnection() {
setIsTesting(true);
setTestResult(null);
try {
const resp = await fetch("/api/prowlarr/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="search" size="md" />
{t("settings.prowlarr")}
</CardTitle>
<CardDescription>{t("settings.prowlarrDesc")}</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.prowlarrUrl")}</label>
<FormInput
type="url"
placeholder={t("settings.prowlarrUrlPlaceholder")}
value={prowlarrUrl}
onChange={(e) => setProwlarrUrl(e.target.value)}
onBlur={() => saveProwlarr()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
<FormInput
type="password"
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
value={prowlarrApiKey}
onChange={(e) => setProwlarrApiKey(e.target.value)}
onBlur={() => saveProwlarr()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrCategories")}</label>
<FormInput
type="text"
placeholder="7030, 7020"
value={prowlarrCategories}
onChange={(e) => setProwlarrCategories(e.target.value)}
onBlur={() => saveProwlarr()}
/>
<p className="text-xs text-muted-foreground mt-1">{t("settings.prowlarrCategoriesHelp")}</p>
</FormField>
</FormRow>
<div className="flex items-center gap-3">
<Button
onClick={handleTestConnection}
disabled={isTesting || !prowlarrUrl || !prowlarrApiKey}
>
{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

@@ -876,3 +876,37 @@ export async function getMetadataBatchResults(jobId: string, status?: string) {
const params = status ? `?status=${status}` : "";
return apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${jobId}/results${params}`);
}
// ---------------------------------------------------------------------------
// Prowlarr
// ---------------------------------------------------------------------------
export type ProwlarrCategory = {
id: number;
name: string | null;
};
export type ProwlarrRelease = {
guid: string;
title: string;
size: number;
downloadUrl: string | null;
indexer: string | null;
seeders: number | null;
leechers: number | null;
publishDate: string | null;
protocol: string | null;
infoUrl: string | null;
categories: ProwlarrCategory[] | null;
};
export type ProwlarrSearchResponse = {
results: ProwlarrRelease[];
query: string;
};
export type ProwlarrTestResponse = {
success: boolean;
message: string;
indexer_count: number | null;
};

View File

@@ -458,6 +458,40 @@ const en: Record<TranslationKey, string> = {
"settings.newTargetPlaceholder": "New target status (e.g. hiatus)",
"settings.createTargetStatus": "Create status",
// Settings - Prowlarr
"settings.prowlarr": "Prowlarr",
"settings.prowlarrDesc": "Configure Prowlarr to search for releases on indexers (torrents/usenet). Only manual search is available for now.",
"settings.prowlarrUrl": "Prowlarr URL",
"settings.prowlarrUrlPlaceholder": "http://localhost:9696",
"settings.prowlarrApiKey": "API Key",
"settings.prowlarrApiKeyPlaceholder": "Prowlarr API key",
"settings.prowlarrCategories": "Categories",
"settings.prowlarrCategoriesHelp": "Comma-separated Newznab category IDs (7030 = Comics, 7020 = Ebooks)",
"settings.testConnection": "Test connection",
"settings.testing": "Testing...",
"settings.testSuccess": "Connection successful",
"settings.testFailed": "Connection failed",
// Prowlarr search modal
"prowlarr.searchButton": "Prowlarr",
"prowlarr.modalTitle": "Prowlarr Search",
"prowlarr.searchSeries": "Search series",
"prowlarr.searchVolume": "Search",
"prowlarr.searching": "Searching...",
"prowlarr.noResults": "No results found",
"prowlarr.resultCount": "{{count}} result{{plural}}",
"prowlarr.missingVolumes": "Missing volumes",
"prowlarr.columnTitle": "Title",
"prowlarr.columnIndexer": "Indexer",
"prowlarr.columnSize": "Size",
"prowlarr.columnSeeders": "Seeds",
"prowlarr.columnLeechers": "Peers",
"prowlarr.columnProtocol": "Protocol",
"prowlarr.searchError": "Search failed",
"prowlarr.notConfigured": "Prowlarr is not configured",
"prowlarr.download": "Download",
"prowlarr.info": "Info",
// Settings - Language
"settings.language": "Language",
"settings.languageDesc": "Choose the interface language",

View File

@@ -456,6 +456,40 @@ const fr = {
"settings.newTargetPlaceholder": "Nouveau statut cible (ex: hiatus)",
"settings.createTargetStatus": "Créer un statut",
// Settings - Prowlarr
"settings.prowlarr": "Prowlarr",
"settings.prowlarrDesc": "Configurer Prowlarr pour rechercher des releases sur les indexeurs (torrents/usenet). Seule la recherche manuelle est disponible pour le moment.",
"settings.prowlarrUrl": "URL Prowlarr",
"settings.prowlarrUrlPlaceholder": "http://localhost:9696",
"settings.prowlarrApiKey": "Clé API",
"settings.prowlarrApiKeyPlaceholder": "Clé API Prowlarr",
"settings.prowlarrCategories": "Catégories",
"settings.prowlarrCategoriesHelp": "ID de catégories Newznab séparés par des virgules (7030 = Comics, 7020 = Ebooks)",
"settings.testConnection": "Tester la connexion",
"settings.testing": "Test en cours...",
"settings.testSuccess": "Connexion réussie",
"settings.testFailed": "Échec de la connexion",
// Prowlarr search modal
"prowlarr.searchButton": "Prowlarr",
"prowlarr.modalTitle": "Recherche Prowlarr",
"prowlarr.searchSeries": "Rechercher la série",
"prowlarr.searchVolume": "Rechercher",
"prowlarr.searching": "Recherche en cours...",
"prowlarr.noResults": "Aucun résultat trouvé",
"prowlarr.resultCount": "{{count}} résultat{{plural}}",
"prowlarr.missingVolumes": "Volumes manquants",
"prowlarr.columnTitle": "Titre",
"prowlarr.columnIndexer": "Indexeur",
"prowlarr.columnSize": "Taille",
"prowlarr.columnSeeders": "Seeds",
"prowlarr.columnLeechers": "Peers",
"prowlarr.columnProtocol": "Protocole",
"prowlarr.searchError": "Erreur lors de la recherche",
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
"prowlarr.download": "Télécharger",
"prowlarr.info": "Info",
// Settings - Language
"settings.language": "Langue",
"settings.languageDesc": "Choisir la langue de l'interface",