Refactor indexer management and improve search logic

Refactors admin settings to use a new IndexersTab and card-based indexer management UI, supporting category selection and improved configuration. Updates backend and API routes to handle indexer categories, propagate ASIN for better search scoring, and group indexers by categories to optimize Prowlarr searches. Enhances documentation to clarify non-terminal request matching and auto-completion behavior. Adds new reusable components for indexer management and category selection.
This commit is contained in:
kikootwo
2026-01-13 21:32:54 -05:00
parent e346f88f42
commit 307b63fab4
30 changed files with 1787 additions and 671 deletions
@@ -0,0 +1,76 @@
/**
* Component: Available Indexer Row
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Button } from '@/components/ui/Button';
interface AvailableIndexerRowProps {
indexer: {
id: number;
name: string;
protocol: string;
supportsRss: boolean;
};
isAdded: boolean;
onAdd: () => void;
}
export function AvailableIndexerRow({
indexer,
isAdded,
onAdd,
}: AvailableIndexerRowProps) {
return (
<div
className={`flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0 ${
isAdded ? 'opacity-60' : ''
}`}
>
{/* Indexer Info */}
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{indexer.name}
</span>
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{indexer.protocol}
</span>
</div>
</div>
</div>
{/* Action */}
<div>
{isAdded ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<svg
className="w-4 h-4 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-sm font-medium text-green-700 dark:text-green-300">
Added
</span>
</div>
) : (
<Button onClick={onAdd} variant="primary" size="sm">
Add
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,165 @@
/**
* Component: Category Tree View with Toggle Switches
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import {
TORRENT_CATEGORIES,
getChildIds,
areAllChildrenSelected,
isParentCategory,
} from '@/lib/utils/torrent-categories';
interface CategoryTreeViewProps {
selectedCategories: number[];
onChange: (categories: number[]) => void;
}
export function CategoryTreeView({
selectedCategories,
onChange,
}: CategoryTreeViewProps) {
const handleParentToggle = (parentId: number) => {
const childIds = getChildIds(parentId);
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
if (allChildrenSelected) {
// Deselect parent and all children
onChange(
selectedCategories.filter(
(id) => id !== parentId && !childIds.includes(id)
)
);
} else {
// Select parent and all children
const newSelection = new Set(selectedCategories);
newSelection.add(parentId);
childIds.forEach((id) => newSelection.add(id));
onChange(Array.from(newSelection));
}
};
const handleChildToggle = (childId: number) => {
const isSelected = selectedCategories.includes(childId);
if (isSelected) {
// Deselect child
onChange(selectedCategories.filter((id) => id !== childId));
} else {
// Select child
onChange([...selectedCategories, childId]);
}
};
const isParentSelected = (parentId: number) => {
return areAllChildrenSelected(parentId, selectedCategories);
};
const isChildSelected = (childId: number) => {
return selectedCategories.includes(childId);
};
return (
<div className="space-y-5">
{TORRENT_CATEGORIES.map((category) => (
<div key={category.id} className="space-y-2">
{/* Parent Category Header */}
<div className="flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-3">
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
{category.name}
</span>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{category.id}]
</span>
{category.id === 3030 && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
)}
</div>
<ToggleSwitch
checked={isParentCategory(category.id) ? isParentSelected(category.id) : isChildSelected(category.id)}
onChange={() => {
if (isParentCategory(category.id)) {
handleParentToggle(category.id);
} else {
handleChildToggle(category.id);
}
}}
disabled={false}
/>
</div>
{/* Child Categories */}
{category.children && category.children.length > 0 && (
<div className="ml-4 space-y-2">
{category.children.map((child) => (
<div
key={child.id}
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">
{child.name}
</span>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{child.id}]
</span>
{child.id === 3030 && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
)}
</div>
<ToggleSwitch
checked={isChildSelected(child.id)}
onChange={() => handleChildToggle(child.id)}
disabled={isParentSelected(category.id)}
/>
</div>
))}
</div>
)}
</div>
))}
</div>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: () => void;
disabled: boolean;
}
function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<button
type="button"
onClick={onChange}
disabled={disabled}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800
${
checked
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-700'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
aria-checked={checked}
role="switch"
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out shadow-lg
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}
@@ -0,0 +1,78 @@
/**
* Component: Delete Confirmation Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
interface DeleteConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
indexerName: string;
}
export function DeleteConfirmModal({
isOpen,
onClose,
onConfirm,
indexerName,
}: DeleteConfirmModalProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Remove Indexer"
size="sm"
>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<svg
className="w-6 h-6 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="flex-1">
<p className="text-sm text-gray-700 dark:text-gray-300">
Are you sure you want to remove <span className="font-semibold">{indexerName}</span>?
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
This indexer will no longer be used for searches. You can add it back later if needed.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button onClick={onClose} variant="outline">
Cancel
</Button>
<Button
onClick={handleConfirm}
className="bg-red-600 hover:bg-red-700 text-white"
>
Remove Indexer
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,81 @@
/**
* Component: Indexer Card
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
interface IndexerCardProps {
indexer: {
id: number;
name: string;
protocol: string;
};
onEdit: () => void;
onDelete: () => void;
}
export function IndexerCard({ indexer, onEdit, onDelete }: IndexerCardProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between gap-3">
{/* Indexer Info */}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate mb-1">
{indexer.name}
</h3>
<span className="inline-block text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{indexer.protocol}
</span>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Edit Button */}
<button
onClick={onEdit}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Edit indexer"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* Delete Button */}
<button
onClick={onDelete}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Delete indexer"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,280 @@
/**
* Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState, useEffect } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView';
import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories';
interface IndexerConfigModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'add' | 'edit';
indexer: {
id: number;
name: string;
protocol: string;
supportsRss: boolean;
};
initialConfig?: {
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
categories: number[];
};
onSave: (config: {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
categories: number[];
}) => void;
}
export function IndexerConfigModal({
isOpen,
onClose,
mode,
indexer,
initialConfig,
onSave,
}: IndexerConfigModalProps) {
// Default values for Add mode
const defaults = {
priority: 10,
seedingTimeMinutes: 0,
rssEnabled: indexer.supportsRss,
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
};
// Form state
const [priority, setPriority] = useState(
initialConfig?.priority ?? defaults.priority
);
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
);
const [rssEnabled, setRssEnabled] = useState(
initialConfig?.rssEnabled ?? defaults.rssEnabled
);
const [selectedCategories, setSelectedCategories] = useState<number[]>(
initialConfig?.categories ?? defaults.categories
);
// Validation errors
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
categories?: string;
}>({});
// Reset form when modal opens or indexer changes
useEffect(() => {
if (isOpen) {
if (mode === 'add') {
setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRssEnabled(defaults.rssEnabled);
setSelectedCategories(defaults.categories);
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
}
setErrors({});
}
}, [isOpen, mode, indexer.id]);
const validate = () => {
const newErrors: typeof errors = {};
if (priority < 1 || priority > 25) {
newErrors.priority = 'Priority must be between 1 and 25';
}
if (seedingTimeMinutes < 0) {
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (selectedCategories.length === 0) {
newErrors.categories = 'At least one category must be selected';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (!validate()) {
return;
}
onSave({
id: indexer.id,
name: indexer.name,
priority,
seedingTimeMinutes,
rssEnabled: indexer.supportsRss ? rssEnabled : false,
categories: selectedCategories,
});
onClose();
};
const handlePriorityChange = (value: string) => {
const parsed = parseInt(value);
if (!isNaN(parsed)) {
// Clamp value between 1 and 25
setPriority(Math.max(1, Math.min(25, parsed)));
} else if (value === '') {
setPriority(1);
}
};
const handleSeedingTimeChange = (value: string) => {
if (value === '') {
setSeedingTimeMinutes(0);
} else {
const parsed = parseInt(value);
if (!isNaN(parsed)) {
setSeedingTimeMinutes(Math.max(0, parsed));
}
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={mode === 'add' ? 'Add Indexer' : 'Edit Indexer'}
size="md"
>
<div className="space-y-6">
{/* Indexer Info (readonly) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Indexer
</label>
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<span className="text-base font-medium text-gray-900 dark:text-gray-100">
{indexer.name}
</span>
<span className="text-xs px-2 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{indexer.protocol}
</span>
</div>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority (1-25)
</label>
<Input
type="number"
min="1"
max="25"
value={priority}
onChange={(e) => handlePriorityChange(e.target.value)}
className={errors.priority ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Higher values = preferred in ranking algorithm
</p>
{errors.priority && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.priority}
</p>
)}
</div>
{/* Seeding Time */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
{/* RSS Monitoring */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
RSS Monitoring
</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={rssEnabled}
onChange={(e) => setRssEnabled(e.target.checked)}
disabled={!indexer.supportsRss}
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
Auto-check RSS feeds every 15 minutes
</span>
</div>
{!indexer.supportsRss && (
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-2">
This indexer does not support RSS monitoring
</p>
)}
</div>
{/* Categories */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Categories
</label>
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<CategoryTreeView
selectedCategories={selectedCategories}
onChange={setSelectedCategories}
/>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Select categories to search on this indexer. Parent selection locks all children as selected.
</p>
{errors.categories && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.categories}
</p>
)}
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button onClick={onClose} variant="outline">
Cancel
</Button>
<Button onClick={handleSave} variant="primary">
{mode === 'add' ? 'Add Indexer' : 'Save Changes'}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,285 @@
/**
* Component: Indexer Management Container
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { IndexerCard } from './IndexerCard';
import { IndexerConfigModal } from './IndexerConfigModal';
import { AvailableIndexerRow } from './AvailableIndexerRow';
import { DeleteConfirmModal } from './DeleteConfirmModal';
import { fetchWithAuth } from '@/lib/utils/api';
interface ProwlarrIndexer {
id: number;
name: string;
protocol: string;
supportsRss: boolean;
}
interface SavedIndexerConfig {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
categories: number[];
}
interface IndexerManagementProps {
prowlarrUrl: string;
prowlarrApiKey: string;
mode: 'wizard' | 'settings';
initialIndexers?: SavedIndexerConfig[];
onIndexersChange?: (indexers: SavedIndexerConfig[]) => void;
}
export function IndexerManagement({
prowlarrUrl,
prowlarrApiKey,
mode,
initialIndexers = [],
onIndexersChange,
}: IndexerManagementProps) {
const [fetchedIndexers, setFetchedIndexers] = useState<ProwlarrIndexer[]>([]);
const [configuredIndexers, setConfiguredIndexers] = useState<SavedIndexerConfig[]>(initialIndexers);
const [modalState, setModalState] = useState<{
isOpen: boolean;
mode: 'add' | 'edit';
indexer?: ProwlarrIndexer;
currentConfig?: SavedIndexerConfig;
}>({ isOpen: false, mode: 'add' });
const [deleteModalState, setDeleteModalState] = useState<{
isOpen: boolean;
indexerId?: number;
indexerName?: string;
}>({ isOpen: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Sync with parent when configuredIndexers changes
useEffect(() => {
if (onIndexersChange) {
onIndexersChange(configuredIndexers);
}
}, [configuredIndexers, onIndexersChange]);
// Sync with initialIndexers prop changes
useEffect(() => {
setConfiguredIndexers(initialIndexers);
}, [initialIndexers]);
const fetchIndexers = async () => {
setLoading(true);
setError(null);
try {
const endpoint = mode === 'wizard'
? '/api/setup/test-prowlarr'
: '/api/admin/settings/test-prowlarr';
// Use fetchWithAuth for settings mode (requires authentication)
// Use plain fetch for wizard mode (no auth required)
const response = mode === 'settings'
? await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: prowlarrUrl,
apiKey: prowlarrApiKey,
}),
})
: await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: prowlarrUrl,
apiKey: prowlarrApiKey,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch indexers');
}
setFetchedIndexers(data.indexers || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch indexers');
setFetchedIndexers([]);
} finally {
setLoading(false);
}
};
const openAddModal = (indexer: ProwlarrIndexer) => {
setModalState({
isOpen: true,
mode: 'add',
indexer,
});
};
const openEditModal = (config: SavedIndexerConfig) => {
// Find the full indexer info from fetched list
const indexer = fetchedIndexers.find((idx) => idx.id === config.id);
setModalState({
isOpen: true,
mode: 'edit',
indexer: indexer || {
id: config.id,
name: config.name,
protocol: 'torrent', // Default fallback
supportsRss: config.rssEnabled,
},
currentConfig: config,
});
};
const closeModal = () => {
setModalState({ isOpen: false, mode: 'add' });
};
const handleSave = (config: SavedIndexerConfig) => {
if (modalState.mode === 'add') {
// Add new indexer
setConfiguredIndexers([...configuredIndexers, config]);
} else {
// Update existing indexer
setConfiguredIndexers(
configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
)
);
}
};
const handleDelete = (id: number) => {
const indexer = configuredIndexers.find((idx) => idx.id === id);
if (!indexer) return;
setDeleteModalState({
isOpen: true,
indexerId: id,
indexerName: indexer.name,
});
};
const confirmDelete = () => {
if (deleteModalState.indexerId) {
setConfiguredIndexers(
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
);
}
};
const isIndexerAdded = (id: number) => {
return configuredIndexers.some((idx) => idx.id === id);
};
return (
<div className="space-y-6">
{/* Section 1: Available Indexers */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Available Indexers
</h3>
<Button
onClick={fetchIndexers}
loading={loading}
variant="outline"
disabled={!prowlarrUrl || !prowlarrApiKey}
>
{configuredIndexers.length > 0 || fetchedIndexers.length > 0
? 'Refresh Indexers'
: 'Fetch Indexers'}
</Button>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
{fetchedIndexers.length > 0 && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
{fetchedIndexers.map((indexer) => (
<AvailableIndexerRow
key={indexer.id}
indexer={indexer}
isAdded={isIndexerAdded(indexer.id)}
onAdd={() => openAddModal(indexer)}
/>
))}
</div>
)}
{!loading && fetchedIndexers.length === 0 && !error && (
<div className="text-sm text-gray-500 dark:text-gray-400 py-6 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
{prowlarrUrl && prowlarrApiKey
? 'Click "Fetch Indexers" to load available indexers from Prowlarr.'
: 'Enter Prowlarr URL and API key above, then fetch indexers.'}
</div>
)}
</div>
{/* Section 2: Configured Indexers */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Configured Indexers ({configuredIndexers.length})
</h3>
{configuredIndexers.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400 py-8 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<p className="mb-2">No indexers configured yet</p>
<p className="text-xs">
Fetch indexers from Prowlarr and click "Add" to configure them.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configuredIndexers.map((config) => (
<IndexerCard
key={config.id}
indexer={{
id: config.id,
name: config.name,
protocol: 'torrent', // Will be populated correctly from fetched data
}}
onEdit={() => openEditModal(config)}
onDelete={() => handleDelete(config.id)}
/>
))}
</div>
)}
</div>
{/* Config Modal */}
{modalState.isOpen && modalState.indexer && (
<IndexerConfigModal
isOpen={modalState.isOpen}
onClose={closeModal}
mode={modalState.mode}
indexer={modalState.indexer}
initialConfig={modalState.currentConfig}
onSave={handleSave}
/>
)}
{/* Delete Confirmation Modal */}
<DeleteConfirmModal
isOpen={deleteModalState.isOpen}
onClose={() => setDeleteModalState({ isOpen: false })}
onConfirm={confirmDelete}
indexerName={deleteModalState.indexerName || ''}
/>
</div>
);
}
@@ -77,8 +77,9 @@ export function InteractiveTorrentSearchModal({
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else {
// New flow: search by custom title + original author
data = await searchByAudiobook(searchTitle, audiobook.author);
// New flow: search by custom title + original author + optional ASIN for size scoring
const asin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
}
setResults(data || []);
} catch (err) {