mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
+41
-277
@@ -12,6 +12,7 @@ import Link from 'next/link';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
import { IndexersTab } from './tabs/IndexersTab';
|
||||
|
||||
interface PlexLibrary {
|
||||
id: string;
|
||||
@@ -28,6 +29,7 @@ interface IndexerConfig {
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories?: number[];
|
||||
supportsRss?: boolean;
|
||||
}
|
||||
|
||||
@@ -115,6 +117,7 @@ export default function AdminSettings() {
|
||||
const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]);
|
||||
const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]);
|
||||
const [indexers, setIndexers] = useState<IndexerConfig[]>([]);
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<Array<{id: number; name: string; priority: number; seedingTimeMinutes: number; rssEnabled: boolean; categories: number[]}>>([]);
|
||||
const [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
|
||||
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
|
||||
const [isLocalAdmin, setIsLocalAdmin] = useState(false);
|
||||
@@ -310,6 +313,19 @@ export default function AdminSettings() {
|
||||
const data = await response.json();
|
||||
setIndexers(data.indexers || []);
|
||||
setFlagConfigs(data.flagConfigs || []);
|
||||
|
||||
// Extract configured indexers (enabled ones) for the new IndexerManagement component
|
||||
const configured = (data.indexers || [])
|
||||
.filter((idx: IndexerConfig) => idx.enabled)
|
||||
.map((idx: IndexerConfig) => ({
|
||||
id: idx.id,
|
||||
name: idx.name,
|
||||
priority: idx.priority,
|
||||
seedingTimeMinutes: idx.seedingTimeMinutes,
|
||||
rssEnabled: idx.rssEnabled,
|
||||
categories: idx.categories || [3030], // Include categories, default to audiobooks
|
||||
}));
|
||||
setConfiguredIndexers(configured);
|
||||
} else {
|
||||
console.error('Failed to fetch indexers:', response.status);
|
||||
// Don't show error on initial load, only if user explicitly tries to load
|
||||
@@ -935,17 +951,21 @@ export default function AdminSettings() {
|
||||
throw new Error('Failed to save Prowlarr settings');
|
||||
}
|
||||
|
||||
// Save indexer configuration and flag configs if indexers are loaded
|
||||
if (indexers.length > 0) {
|
||||
const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ indexers, flagConfigs }),
|
||||
});
|
||||
// Save indexer configuration and flag configs
|
||||
// Convert configured indexers to the format expected by the API (with enabled: true)
|
||||
const indexersForSave = configuredIndexers.map((idx) => ({
|
||||
...idx,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
if (!indexersResponse.ok) {
|
||||
throw new Error('Failed to save indexer configuration');
|
||||
}
|
||||
const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ indexers: indexersForSave, flagConfigs }),
|
||||
});
|
||||
|
||||
if (!indexersResponse.ok) {
|
||||
throw new Error('Failed to save indexer configuration');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1441,273 +1461,17 @@ export default function AdminSettings() {
|
||||
|
||||
{/* Prowlarr/Indexers Tab */}
|
||||
{activeTab === 'prowlarr' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Indexer Configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Prowlarr connection and select which indexers to use with priority and seeding time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Prowlarr Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.prowlarr.url}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, url: e.target.value },
|
||||
});
|
||||
// Only invalidate if URL actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.url) {
|
||||
setValidated({ ...validated, prowlarr: false });
|
||||
}
|
||||
}}
|
||||
placeholder="http://localhost:9696"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Prowlarr API Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.prowlarr.apiKey}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, apiKey: e.target.value },
|
||||
});
|
||||
// Only invalidate if API key actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.apiKey) {
|
||||
setValidated({ ...validated, prowlarr: false });
|
||||
}
|
||||
}}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testProwlarrConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.prowlarr.url || !settings.prowlarr.apiKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{(() => {
|
||||
if (originalSettings &&
|
||||
settings.prowlarr.url === originalSettings.prowlarr.url &&
|
||||
settings.prowlarr.apiKey === originalSettings.prowlarr.apiKey) {
|
||||
return 'Refresh Indexers';
|
||||
}
|
||||
return 'Test Connection';
|
||||
})()}
|
||||
</Button>
|
||||
{testResults.prowlarr && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResults.prowlarr.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResults.prowlarr.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Indexer Configuration
|
||||
</h3>
|
||||
{indexers.length > 0 && !loadingIndexers && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{indexers.filter(idx => idx.enabled).length} enabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loadingIndexers ? (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-gray-500">Loading indexers...</span>
|
||||
</div>
|
||||
) : indexers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{indexers.map((indexer) => (
|
||||
<div
|
||||
key={indexer.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={indexer.enabled}
|
||||
onChange={(e) => {
|
||||
setIndexers(
|
||||
indexers.map((idx) =>
|
||||
idx.id === indexer.id
|
||||
? { ...idx, enabled: e.target.checked }
|
||||
: idx
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{indexer.name}
|
||||
</h4>
|
||||
<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 className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Priority (1-25)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={indexer.priority}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 10;
|
||||
setIndexers(
|
||||
indexers.map((idx) =>
|
||||
idx.id === indexer.id
|
||||
? { ...idx, priority: Math.max(1, Math.min(25, value)) }
|
||||
: idx
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={!indexer.enabled}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Higher = preferred</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Seeding Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={indexer.seedingTimeMinutes}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseInt(e.target.value);
|
||||
setIndexers(
|
||||
indexers.map((idx) =>
|
||||
idx.id === indexer.id
|
||||
? { ...idx, seedingTimeMinutes: isNaN(value) ? 0 : value }
|
||||
: idx
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={!indexer.enabled}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">0 = unlimited</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
RSS Monitoring
|
||||
</label>
|
||||
<div className="flex items-center h-[42px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={indexer.rssEnabled || false}
|
||||
onChange={(e) => {
|
||||
setIndexers(
|
||||
indexers.map((idx) =>
|
||||
idx.id === indexer.id
|
||||
? { ...idx, rssEnabled: e.target.checked }
|
||||
: idx
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={!indexer.enabled || indexer.supportsRss === false}
|
||||
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Auto check for new releases</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 py-6 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<p className="mb-2">No indexers configured.</p>
|
||||
<p className="text-xs">
|
||||
{settings.prowlarr.url && settings.prowlarr.apiKey
|
||||
? 'Click "Refresh Indexers" above to load available indexers from Prowlarr.'
|
||||
: 'Enter your Prowlarr URL and API key above, then click "Test Connection" to load indexers.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flag Configuration Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Indexer Flag Configuration (Optional)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure score bonuses or penalties for indexer flags like "Freeleech".
|
||||
These modifiers apply universally across all indexers and affect final torrent ranking.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flagConfigs.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{flagConfigs.map((config, index) => (
|
||||
<FlagConfigRow
|
||||
key={index}
|
||||
config={config}
|
||||
onChange={(updated) => {
|
||||
const newConfigs = [...flagConfigs];
|
||||
newConfigs[index] = updated;
|
||||
setFlagConfigs(newConfigs);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setFlagConfigs(flagConfigs.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFlagConfigs([...flagConfigs, { name: '', modifier: 0 }]);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
+ Add Flag Rule
|
||||
</Button>
|
||||
|
||||
{flagConfigs.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3 italic">
|
||||
No flag rules configured. Flag bonuses/penalties are optional.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IndexersTab
|
||||
settings={settings}
|
||||
originalSettings={originalSettings}
|
||||
indexers={configuredIndexers}
|
||||
flagConfigs={flagConfigs}
|
||||
onSettingsChange={setSettings}
|
||||
onIndexersChange={setConfiguredIndexers}
|
||||
onFlagConfigsChange={setFlagConfigs}
|
||||
onValidationChange={setValidated}
|
||||
validated={validated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download Client Tab */}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Component: Indexers Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
interface SavedIndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
|
||||
interface IndexersTabProps {
|
||||
settings: {
|
||||
prowlarr: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
};
|
||||
};
|
||||
originalSettings?: {
|
||||
prowlarr: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
};
|
||||
} | null;
|
||||
indexers: SavedIndexerConfig[];
|
||||
flagConfigs: IndexerFlagConfig[];
|
||||
onSettingsChange: (settings: any) => void;
|
||||
onIndexersChange: (indexers: SavedIndexerConfig[]) => void;
|
||||
onFlagConfigsChange: (configs: IndexerFlagConfig[]) => void;
|
||||
onValidationChange: (validated: any) => void;
|
||||
validated: { prowlarr?: boolean };
|
||||
}
|
||||
|
||||
export function IndexersTab({
|
||||
settings,
|
||||
originalSettings,
|
||||
indexers,
|
||||
flagConfigs,
|
||||
onSettingsChange,
|
||||
onIndexersChange,
|
||||
onFlagConfigsChange,
|
||||
onValidationChange,
|
||||
validated,
|
||||
}: IndexersTabProps) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Indexer Configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Prowlarr connection and manage which indexers to use with priority and seeding time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Prowlarr Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.prowlarr.url}
|
||||
onChange={(e) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, url: e.target.value },
|
||||
});
|
||||
// Only invalidate if URL actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.url) {
|
||||
onValidationChange({ ...validated, prowlarr: false });
|
||||
}
|
||||
}}
|
||||
placeholder="http://localhost:9696"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Prowlarr API Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.prowlarr.apiKey}
|
||||
onChange={(e) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, apiKey: e.target.value },
|
||||
});
|
||||
// Only invalidate if API key actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.apiKey) {
|
||||
onValidationChange({ ...validated, prowlarr: false });
|
||||
}
|
||||
}}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<IndexerManagement
|
||||
prowlarrUrl={settings.prowlarr.url}
|
||||
prowlarrApiKey={settings.prowlarr.apiKey}
|
||||
mode="settings"
|
||||
initialIndexers={indexers}
|
||||
onIndexersChange={onIndexersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flag Configuration Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Indexer Flag Configuration (Optional)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure score bonuses or penalties for indexer flags like "Freeleech".
|
||||
These modifiers apply universally across all indexers and affect final torrent ranking.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flagConfigs.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{flagConfigs.map((config, index) => (
|
||||
<FlagConfigRow
|
||||
key={index}
|
||||
config={config}
|
||||
onChange={(updated) => {
|
||||
const newConfigs = [...flagConfigs];
|
||||
newConfigs[index] = updated;
|
||||
onFlagConfigsChange(newConfigs);
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFlagConfigsChange(flagConfigs.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onFlagConfigsChange([...flagConfigs, { name: '', modifier: 0 }]);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
+ Add Flag Rule
|
||||
</Button>
|
||||
|
||||
{flagConfigs.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3 italic">
|
||||
No flag rules configured. Flag bonuses/penalties are optional.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled?: boolean;
|
||||
categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,16 +49,19 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const indexersWithConfig = indexers.map((indexer: any) => {
|
||||
const saved = savedIndexersMap.get(indexer.id);
|
||||
const isAdded = !!saved;
|
||||
|
||||
return {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
protocol: indexer.protocol,
|
||||
privacy: indexer.privacy,
|
||||
enabled: !!saved, // Enabled if in saved list
|
||||
enabled: isAdded, // Enabled if in saved list
|
||||
isAdded, // Explicit flag for UI (new card-based interface)
|
||||
priority: saved?.priority || 10,
|
||||
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
|
||||
rssEnabled: saved?.rssEnabled ?? false,
|
||||
categories: saved?.categories || [3030], // Default to audiobooks category
|
||||
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
|
||||
};
|
||||
});
|
||||
@@ -101,6 +105,7 @@ export async function PUT(request: NextRequest) {
|
||||
priority: indexer.priority,
|
||||
seedingTimeMinutes: indexer.seedingTimeMinutes,
|
||||
rssEnabled: indexer.rssEnabled || false,
|
||||
categories: indexer.categories || [3030], // Default to audiobooks if not specified
|
||||
}));
|
||||
|
||||
// Save to configuration (matches wizard format)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -17,6 +18,7 @@ const logger = RMABLogger.create('API.AudiobookSearch');
|
||||
const SearchSchema = z.object({
|
||||
title: z.string(),
|
||||
author: z.string(),
|
||||
asin: z.string().optional(), // Optional ASIN for runtime-based size scoring
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { title, author } = SearchSchema.parse(body);
|
||||
const { title, author, asin } = SearchSchema.parse(body);
|
||||
|
||||
// Get enabled indexers from configuration
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
@@ -49,9 +51,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
|
||||
|
||||
if (enabledIndexerIds.length === 0) {
|
||||
if (indexersConfig.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
||||
{ status: 400 }
|
||||
@@ -67,18 +68,43 @@ export async function POST(request: NextRequest) {
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Search Prowlarr for torrents - ONLY enabled indexers
|
||||
const prowlarr = await getProwlarrService();
|
||||
const searchQuery = title; // Title only - cast wide net
|
||||
// Group indexers by their category configuration
|
||||
// This minimizes API calls while ensuring each indexer only searches its configured categories
|
||||
const groups = groupIndexersByCategories(indexersConfig);
|
||||
|
||||
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
|
||||
|
||||
const results = await prowlarr.search(searchQuery, {
|
||||
indexerIds: enabledIndexerIds,
|
||||
maxResults: 100, // Increased limit for broader search
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
|
||||
});
|
||||
|
||||
logger.debug(`Found ${results.length} raw results`, { title, author });
|
||||
// Search Prowlarr for each group and combine results
|
||||
const prowlarr = await getProwlarrService();
|
||||
const searchQuery = title; // Title only - cast wide net
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
maxResults: 100, // Limit per group
|
||||
});
|
||||
|
||||
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Continue with other groups even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
const results = allResults;
|
||||
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
if (results.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -88,8 +114,37 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch runtime from Audnexus if ASIN provided (for size-based scoring/filtering)
|
||||
let durationMinutes: number | undefined;
|
||||
if (asin) {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const runtime = await audibleService.getRuntime(asin);
|
||||
if (runtime) {
|
||||
durationMinutes = runtime;
|
||||
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${asin}`);
|
||||
} else {
|
||||
logger.debug(`No runtime found for ASIN ${asin}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log filter info
|
||||
const sizeMBThreshold = 20;
|
||||
const preFilterCount = results.length;
|
||||
const belowThreshold = results.filter(r => (r.size / (1024 * 1024)) < sizeMBThreshold);
|
||||
if (belowThreshold.length > 0) {
|
||||
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
|
||||
}
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, indexerPriorities, flagConfigs);
|
||||
|
||||
// Log filter results
|
||||
const postFilterCount = rankedResults.length;
|
||||
if (postFilterCount < preFilterCount) {
|
||||
logger.info(`Filtered out ${preFilterCount - postFilterCount} results < ${sizeMBThreshold} MB`);
|
||||
}
|
||||
|
||||
// No threshold filtering - show all results like interactive search
|
||||
// User can see scores and make their own decision
|
||||
@@ -103,12 +158,18 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||
logger.debug('--------------------------------------------------------');
|
||||
top3.forEach((result, index) => {
|
||||
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
|
||||
|
||||
logger.debug(`${index + 1}. "${result.title}"`, {
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
baseScore: `${result.score.toFixed(1)}/100`,
|
||||
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
|
||||
sizeScore: durationMinutes
|
||||
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
|
||||
: 'N/A (no runtime)',
|
||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
|
||||
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
|
||||
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
|
||||
|
||||
@@ -109,6 +109,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search job for request ${newRequest.id}`);
|
||||
|
||||
@@ -70,6 +70,7 @@ export async function POST(
|
||||
id: requestRecord.audiobook.id,
|
||||
title: requestRecord.audiobook.title,
|
||||
author: requestRecord.audiobook.author,
|
||||
asin: requestRecord.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
// Update request status
|
||||
|
||||
@@ -274,6 +274,7 @@ export async function PATCH(
|
||||
id: requestWithData.audiobook.id,
|
||||
title: requestWithData.audiobook.title,
|
||||
author: requestWithData.audiobook.author,
|
||||
asin: requestWithData.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
updated = await prisma.request.update({
|
||||
|
||||
@@ -176,6 +176,7 @@ export async function POST(request: NextRequest) {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
asin: audiobookRecord.audibleAsin || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
|
||||
interface ProwlarrStepProps {
|
||||
prowlarrUrl: string;
|
||||
@@ -17,19 +18,13 @@ interface ProwlarrStepProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface IndexerInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
supportsRss: boolean;
|
||||
}
|
||||
|
||||
interface SelectedIndexer {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
|
||||
export function ProwlarrStep({
|
||||
@@ -39,141 +34,24 @@ export function ProwlarrStep({
|
||||
onNext,
|
||||
onBack,
|
||||
}: ProwlarrStepProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
indexerCount?: number;
|
||||
} | null>(null);
|
||||
const [availableIndexers, setAvailableIndexers] = useState<IndexerInfo[]>([]);
|
||||
const [selectedIndexers, setSelectedIndexers] = useState<Record<number, SelectedIndexer>>({});
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/setup/test-prowlarr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: prowlarrUrl, apiKey: prowlarrApiKey }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected successfully! Found ${data.indexerCount || 0} configured indexers.`,
|
||||
indexerCount: data.indexerCount,
|
||||
});
|
||||
setAvailableIndexers(data.indexers || []);
|
||||
|
||||
// Auto-select all indexers with default priority of 10, seeding time of 0 (unlimited), and RSS enabled if supported
|
||||
const autoSelected: Record<number, SelectedIndexer> = {};
|
||||
data.indexers.forEach((indexer: IndexerInfo) => {
|
||||
autoSelected[indexer.id] = {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
|
||||
};
|
||||
});
|
||||
setSelectedIndexers(autoSelected);
|
||||
onUpdate('prowlarrIndexers', Object.values(autoSelected));
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: data.error || 'Connection failed',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Connection test failed',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIndexer = (indexer: IndexerInfo) => {
|
||||
setSelectedIndexers((prev) => {
|
||||
const newSelected = { ...prev };
|
||||
if (newSelected[indexer.id]) {
|
||||
delete newSelected[indexer.id];
|
||||
} else {
|
||||
newSelected[indexer.id] = {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
priority: 10, // Default priority
|
||||
seedingTimeMinutes: 0, // Default: unlimited seeding
|
||||
rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
|
||||
};
|
||||
}
|
||||
onUpdate('prowlarrIndexers', Object.values(newSelected));
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
const updatePriority = (indexerId: number, priority: number) => {
|
||||
setSelectedIndexers((prev) => {
|
||||
const newSelected = { ...prev };
|
||||
if (newSelected[indexerId]) {
|
||||
newSelected[indexerId] = {
|
||||
...newSelected[indexerId],
|
||||
priority: Math.max(1, Math.min(25, priority)), // Clamp between 1-25
|
||||
};
|
||||
}
|
||||
onUpdate('prowlarrIndexers', Object.values(newSelected));
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSeedingTime = (indexerId: number, value: string) => {
|
||||
setSelectedIndexers((prev) => {
|
||||
const newSelected = { ...prev };
|
||||
if (newSelected[indexerId]) {
|
||||
const seedingTimeMinutes = value === '' ? 0 : parseInt(value);
|
||||
newSelected[indexerId] = {
|
||||
...newSelected[indexerId],
|
||||
seedingTimeMinutes: isNaN(seedingTimeMinutes) ? 0 : Math.max(0, seedingTimeMinutes),
|
||||
};
|
||||
}
|
||||
onUpdate('prowlarrIndexers', Object.values(newSelected));
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRss = (indexerId: number) => {
|
||||
setSelectedIndexers((prev) => {
|
||||
const newSelected = { ...prev };
|
||||
if (newSelected[indexerId]) {
|
||||
newSelected[indexerId] = {
|
||||
...newSelected[indexerId],
|
||||
rssEnabled: !newSelected[indexerId].rssEnabled,
|
||||
};
|
||||
}
|
||||
onUpdate('prowlarrIndexers', Object.values(newSelected));
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
// Sync configured indexers with parent
|
||||
useEffect(() => {
|
||||
onUpdate('prowlarrIndexers', configuredIndexers);
|
||||
}, [configuredIndexers, onUpdate]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (!testResult?.success) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Please test the connection before proceeding',
|
||||
});
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!prowlarrUrl || !prowlarrApiKey) {
|
||||
setErrorMessage('Please enter Prowlarr URL and API key');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(selectedIndexers).length === 0) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Please select at least one indexer',
|
||||
});
|
||||
if (configuredIndexers.length === 0) {
|
||||
setErrorMessage('Please add at least one indexer');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,207 +100,52 @@ export function ProwlarrStep({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
disabled={!prowlarrUrl || !prowlarrApiKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-lg p-4 ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className={`w-6 h-6 flex-shrink-0 ${
|
||||
testResult.success
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
className="w-6 h-6 flex-shrink-0 text-red-600 dark:text-red-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
{testResult.success ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3
|
||||
className={`text-sm font-medium ${
|
||||
testResult.success
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? 'Success' : 'Error'}
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm mt-1 ${
|
||||
testResult.success
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
<p className="text-sm mt-1 text-red-700 dark:text-red-300">
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indexer Selection */}
|
||||
{availableIndexers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Select Indexers & Configure (Priority: 1-25, Seeding Time, RSS)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Higher priority indexers (closer to 25) will be preferred when ranking search results.
|
||||
Seeding time is in minutes (0 = unlimited). Files will be kept until the seeding requirement is met.
|
||||
Enable RSS to automatically monitor indexer feeds for new releases matching your missing list (default: every 15 minutes, configurable in scheduled jobs settings).
|
||||
</p>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableIndexers.map((indexer) => (
|
||||
<div
|
||||
key={indexer.id}
|
||||
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`indexer-${indexer.id}`}
|
||||
checked={!!selectedIndexers[indexer.id]}
|
||||
onChange={() => toggleIndexer(indexer)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`indexer-${indexer.id}`}
|
||||
className="flex-1 text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
{indexer.name}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
({indexer.protocol})
|
||||
</span>
|
||||
</label>
|
||||
{selectedIndexers[indexer.id] && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={`priority-${indexer.id}`}
|
||||
className="text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Priority:
|
||||
</label>
|
||||
<input
|
||||
id={`priority-${indexer.id}`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={selectedIndexers[indexer.id].priority}
|
||||
onChange={(e) =>
|
||||
updatePriority(indexer.id, parseInt(e.target.value) || 10)
|
||||
}
|
||||
className="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={`seeding-${indexer.id}`}
|
||||
className="text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Seeding (min):
|
||||
</label>
|
||||
<input
|
||||
id={`seeding-${indexer.id}`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={selectedIndexers[indexer.id].seedingTimeMinutes}
|
||||
onChange={(e) =>
|
||||
updateSeedingTime(indexer.id, e.target.value)
|
||||
}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="0 = ∞"
|
||||
/>
|
||||
</div>
|
||||
{indexer.supportsRss && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={`rss-${indexer.id}`}
|
||||
className="text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
RSS:
|
||||
</label>
|
||||
<input
|
||||
id={`rss-${indexer.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedIndexers[indexer.id].rssEnabled}
|
||||
onChange={() => toggleRss(indexer.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Selected: {Object.keys(selectedIndexers).length} of {availableIndexers.length} indexers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
About Prowlarr Indexers
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
Prowlarr searches across multiple torrent indexers. Select which indexers to use and assign priorities to control
|
||||
how search results are ranked. Make sure you have at least one indexer configured in Prowlarr before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
{/* Indexer Management Component */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<IndexerManagement
|
||||
prowlarrUrl={prowlarrUrl}
|
||||
prowlarrApiKey={prowlarrApiKey}
|
||||
mode="wizard"
|
||||
initialIndexers={configuredIndexers}
|
||||
onIndexersChange={setConfiguredIndexers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext}>Next</Button>
|
||||
<Button onClick={handleNext} variant="primary">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user