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
+14 -6
View File
@@ -29,8 +29,8 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
1. **search_indexers** - Search Prowlarr for torrents 1. **search_indexers** - Search Prowlarr for torrents
2. **monitor_download** - Poll progress (10s intervals) 2. **monitor_download** - Poll progress (10s intervals)
3. **organize_files** - Move to media library, set status to 'downloaded' 3. **organize_files** - Move to media library, set status to 'downloaded'
4. **scan_plex** - Full scan of Plex library, match 'downloaded' requests 4. **scan_plex** - Full scan of library, match all non-terminal requests (excludes: available, cancelled)
5. **plex_recently_added_check** - Lightweight polling of recently added items (top 10) 5. **plex_recently_added_check** - Lightweight polling of recently added items, match all non-terminal requests
6. **match_plex** - Fuzzy match to Plex item (deprecated - now handled by scan_plex) 6. **match_plex** - Fuzzy match to Plex item (deprecated - now handled by scan_plex)
## Special Behaviors ## Special Behaviors
@@ -57,10 +57,18 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
- No longer triggers immediate match_plex job - No longer triggers immediate match_plex job
**scan_plex:** **scan_plex:**
- Scans Plex library and populates plex_library table - Full library scan (Plex/Audiobookshelf) and populates plex_library table
- After scan, checks for requests with status 'downloaded' - Checks all non-terminal request statuses for matches (excludes: available, cancelled)
- Fuzzy matches downloaded requests against Plex library (70% threshold) - Fuzzy matches via ASIN/ISBN/title/author (70% threshold)
- Matched requests → 'available' status with plexGuid linked - Matched requests → 'available' status with plexGuid/absItemId linked
- Clears errorMessage and retry counters on match
- Use case: Manual library imports automatically complete stuck requests
**plex_recently_added_check:**
- Polls recently added items (top 10) every 5 minutes
- Matches all non-terminal request statuses against new library items
- Same matching logic as scan_plex (ASIN priority, fuzzy fallback)
- Clears error state and retry counters on match
## Job Payloads ## Job Payloads
+34
View File
@@ -80,6 +80,40 @@ API Docs: `/PlexMediaServerAPIDocs.json`
**Benefits:** Lightweight polling for new items + comprehensive matching for downloaded requests **Benefits:** Lightweight polling for new items + comprehensive matching for downloaded requests
**Note:** Requests transition: pending → searching → downloading → processing → downloaded → available (after detection) **Note:** Requests transition: pending → searching → downloading → processing → downloaded → available (after detection)
## Auto-Completion of Stuck Requests
Library scans (full and incremental) now check **all non-terminal requests** for matches:
**Eligible statuses:**
- pending, searching, downloading, processing, downloaded
- failed, awaiting_search, awaiting_import, warn
**Excluded statuses:**
- available (already completed)
- cancelled (user cancelled)
**Use Case:**
1. Request stuck in 'awaiting_search' or 'failed' status
2. User manually imports audiobook to library (via Plex/ABS or external tool)
3. Next library scan (manual trigger or scheduled recently-added check)
4. Request auto-matches and marks as 'available'
5. Error messages and retry counters cleared
**State Cleanup on Match:**
- errorMessage → null
- searchAttempts → 0
- downloadAttempts → 0
- importAttempts → 0
- completedAt → scan timestamp
**Edge Cases:**
- Active downloads/jobs continue but become no-ops (download completes, organize skips)
- Torrent/NZB remains in download client (manual cleanup if desired)
**Logging:**
- Transitions from non-downloaded statuses logged with original status: `Match found! "Book" → "Library Book" (was 'failed')`
- Provides visibility into which stuck requests were auto-completed
## Data Models ## Data Models
```typescript ```typescript
+41 -277
View File
@@ -12,6 +12,7 @@ import Link from 'next/link';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
import { IndexersTab } from './tabs/IndexersTab';
interface PlexLibrary { interface PlexLibrary {
id: string; id: string;
@@ -28,6 +29,7 @@ interface IndexerConfig {
priority: number; priority: number;
seedingTimeMinutes: number; seedingTimeMinutes: number;
rssEnabled: boolean; rssEnabled: boolean;
categories?: number[];
supportsRss?: boolean; supportsRss?: boolean;
} }
@@ -115,6 +117,7 @@ export default function AdminSettings() {
const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]); const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]);
const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]); const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]);
const [indexers, setIndexers] = useState<IndexerConfig[]>([]); 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 [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]); const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
const [isLocalAdmin, setIsLocalAdmin] = useState(false); const [isLocalAdmin, setIsLocalAdmin] = useState(false);
@@ -310,6 +313,19 @@ export default function AdminSettings() {
const data = await response.json(); const data = await response.json();
setIndexers(data.indexers || []); setIndexers(data.indexers || []);
setFlagConfigs(data.flagConfigs || []); 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 { } else {
console.error('Failed to fetch indexers:', response.status); console.error('Failed to fetch indexers:', response.status);
// Don't show error on initial load, only if user explicitly tries to load // 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'); throw new Error('Failed to save Prowlarr settings');
} }
// Save indexer configuration and flag configs if indexers are loaded // Save indexer configuration and flag configs
if (indexers.length > 0) { // Convert configured indexers to the format expected by the API (with enabled: true)
const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { const indexersForSave = configuredIndexers.map((idx) => ({
method: 'PUT', ...idx,
headers: { 'Content-Type': 'application/json' }, enabled: true,
body: JSON.stringify({ indexers, flagConfigs }), }));
});
if (!indexersResponse.ok) { const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
throw new Error('Failed to save indexer configuration'); 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; break;
@@ -1441,273 +1461,17 @@ export default function AdminSettings() {
{/* Prowlarr/Indexers Tab */} {/* Prowlarr/Indexers Tab */}
{activeTab === 'prowlarr' && ( {activeTab === 'prowlarr' && (
<div className="space-y-6 max-w-4xl"> <IndexersTab
<div> settings={settings}
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> originalSettings={originalSettings}
Indexer Configuration indexers={configuredIndexers}
</h2> flagConfigs={flagConfigs}
<p className="text-gray-600 dark:text-gray-400 mb-6"> onSettingsChange={setSettings}
Configure your Prowlarr connection and select which indexers to use with priority and seeding time. onIndexersChange={setConfiguredIndexers}
</p> onFlagConfigsChange={setFlagConfigs}
</div> onValidationChange={setValidated}
validated={validated}
<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>
)} )}
{/* Download Client Tab */} {/* Download Client Tab */}
+172
View File
@@ -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; priority: number;
seedingTimeMinutes: number; seedingTimeMinutes: number;
rssEnabled?: boolean; 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 indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id); const saved = savedIndexersMap.get(indexer.id);
const isAdded = !!saved;
return { return {
id: indexer.id, id: indexer.id,
name: indexer.name, name: indexer.name,
protocol: indexer.protocol, protocol: indexer.protocol,
privacy: indexer.privacy, 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, priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0, seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false, rssEnabled: saved?.rssEnabled ?? false,
categories: saved?.categories || [3030], // Default to audiobooks category
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
}; };
}); });
@@ -101,6 +105,7 @@ export async function PUT(request: NextRequest) {
priority: indexer.priority, priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes, seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false, rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
})); }));
// Save to configuration (matches wizard format) // Save to configuration (matches wizard format)
+74 -13
View File
@@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { z } from 'zod'; import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
@@ -17,6 +18,7 @@ const logger = RMABLogger.create('API.AudiobookSearch');
const SearchSchema = z.object({ const SearchSchema = z.object({
title: z.string(), title: z.string(),
author: 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 body = await req.json();
const { title, author } = SearchSchema.parse(body); const { title, author, asin } = SearchSchema.parse(body);
// Get enabled indexers from configuration // Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service'); const { getConfigService } = await import('@/lib/services/config.service');
@@ -49,9 +51,8 @@ export async function POST(request: NextRequest) {
} }
const indexersConfig = JSON.parse(indexersConfigStr); const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) { if (indexersConfig.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' }, { error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 } { status: 400 }
@@ -67,18 +68,43 @@ export async function POST(request: NextRequest) {
const flagConfigStr = await configService.get('indexer_flag_config'); const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers // Group indexers by their category configuration
const prowlarr = await getProwlarrService(); // This minimizes API calls while ensuring each indexer only searches its configured categories
const searchQuery = title; // Title only - cast wide net 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, { // Log each group for transparency
indexerIds: enabledIndexerIds, groups.forEach((group, index) => {
maxResults: 100, // Increased limit for broader search 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) { if (results.length === 0) {
return NextResponse.json({ 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 // 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 // No threshold filtering - show all results like interactive search
// User can see scores and make their own decision // 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(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------'); logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => { 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}"`, { logger.debug(`${index + 1}. "${result.title}"`, {
indexer: result.indexer, indexer: result.indexer,
indexerId: result.indexerId, indexerId: result.indexerId,
baseScore: `${result.score.toFixed(1)}/100`, baseScore: `${result.score.toFixed(1)}/100`,
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`, 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'})`, seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
bonusPoints: `+${result.bonusPoints.toFixed(1)}`, bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`), bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
+1
View File
@@ -109,6 +109,7 @@ async function handler(req: AuthenticatedRequest) {
id: audiobook.id, id: audiobook.id,
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
}); });
logger.info(`Triggered search job for request ${newRequest.id}`); logger.info(`Triggered search job for request ${newRequest.id}`);
@@ -70,6 +70,7 @@ export async function POST(
id: requestRecord.audiobook.id, id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title, title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
asin: requestRecord.audiobook.audibleAsin || undefined,
}); });
// Update request status // Update request status
+1
View File
@@ -274,6 +274,7 @@ export async function PATCH(
id: requestWithData.audiobook.id, id: requestWithData.audiobook.id,
title: requestWithData.audiobook.title, title: requestWithData.audiobook.title,
author: requestWithData.audiobook.author, author: requestWithData.audiobook.author,
asin: requestWithData.audiobook.audibleAsin || undefined,
}); });
updated = await prisma.request.update({ updated = await prisma.request.update({
+1
View File
@@ -176,6 +176,7 @@ export async function POST(request: NextRequest) {
id: audiobookRecord.id, id: audiobookRecord.id,
title: audiobookRecord.title, title: audiobookRecord.title,
author: audiobookRecord.author, author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
}); });
} }
+41 -318
View File
@@ -5,9 +5,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
interface ProwlarrStepProps { interface ProwlarrStepProps {
prowlarrUrl: string; prowlarrUrl: string;
@@ -17,19 +18,13 @@ interface ProwlarrStepProps {
onBack: () => void; onBack: () => void;
} }
interface IndexerInfo {
id: number;
name: string;
protocol: string;
supportsRss: boolean;
}
interface SelectedIndexer { interface SelectedIndexer {
id: number; id: number;
name: string; name: string;
priority: number; priority: number;
seedingTimeMinutes: number; seedingTimeMinutes: number;
rssEnabled: boolean; rssEnabled: boolean;
categories: number[];
} }
export function ProwlarrStep({ export function ProwlarrStep({
@@ -39,141 +34,24 @@ export function ProwlarrStep({
onNext, onNext,
onBack, onBack,
}: ProwlarrStepProps) { }: ProwlarrStepProps) {
const [testing, setTesting] = useState(false); const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]);
const [testResult, setTestResult] = useState<{ const [errorMessage, setErrorMessage] = useState<string | null>(null);
success: boolean;
message: string;
indexerCount?: number;
} | null>(null);
const [availableIndexers, setAvailableIndexers] = useState<IndexerInfo[]>([]);
const [selectedIndexers, setSelectedIndexers] = useState<Record<number, SelectedIndexer>>({});
const testConnection = async () => { // Sync configured indexers with parent
setTesting(true); useEffect(() => {
setTestResult(null); onUpdate('prowlarrIndexers', configuredIndexers);
}, [configuredIndexers, onUpdate]);
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;
});
};
const handleNext = () => { const handleNext = () => {
if (!testResult?.success) { setErrorMessage(null);
setTestResult({
success: false, if (!prowlarrUrl || !prowlarrApiKey) {
message: 'Please test the connection before proceeding', setErrorMessage('Please enter Prowlarr URL and API key');
});
return; return;
} }
if (Object.keys(selectedIndexers).length === 0) { if (configuredIndexers.length === 0) {
setTestResult({ setErrorMessage('Please add at least one indexer');
success: false,
message: 'Please select at least one indexer',
});
return; return;
} }
@@ -222,207 +100,52 @@ export function ProwlarrStep({
</p> </p>
</div> </div>
<Button {errorMessage && (
onClick={testConnection} <div className="rounded-lg p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
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'
}`}
>
<div className="flex gap-3"> <div className="flex gap-3">
<svg <svg
className={`w-6 h-6 flex-shrink-0 ${ className="w-6 h-6 flex-shrink-0 text-red-600 dark:text-red-400"
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
{testResult.success ? ( <path
<path fillRule="evenodd"
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"
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"
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> </svg>
<div> <div>
<h3 <h3 className="text-sm font-medium text-red-800 dark:text-red-200">
className={`text-sm font-medium ${ Error
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3> </h3>
<p <p className="text-sm mt-1 text-red-700 dark:text-red-300">
className={`text-sm mt-1 ${ {errorMessage}
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Indexer Selection */} {/* Indexer Management Component */}
{availableIndexers.length > 0 && ( <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="space-y-3"> <IndexerManagement
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> prowlarrUrl={prowlarrUrl}
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> prowlarrApiKey={prowlarrApiKey}
Select Indexers & Configure (Priority: 1-25, Seeding Time, RSS) mode="wizard"
</h3> initialIndexers={configuredIndexers}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> onIndexersChange={setConfiguredIndexers}
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>
</div> </div>
</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"> <Button onClick={onBack} variant="outline">
Back Back
</Button> </Button>
<Button onClick={handleNext}>Next</Button> <Button onClick={handleNext} variant="primary">
Next
</Button>
</div> </div>
</div> </div>
); );
@@ -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; const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle); data = await searchByRequestId(requestId, customTitle);
} else { } else {
// New flow: search by custom title + original author // New flow: search by custom title + original author + optional ASIN for size scoring
data = await searchByAudiobook(searchTitle, audiobook.author); const asin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
} }
setResults(data || []); setResults(data || []);
} catch (err) { } catch (err) {
+2 -2
View File
@@ -308,7 +308,7 @@ export function useSearchTorrents() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const searchTorrents = async (title: string, author: string) => { const searchTorrents = async (title: string, author: string, asin?: string) => {
if (!accessToken) { if (!accessToken) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
@@ -322,7 +322,7 @@ export function useSearchTorrents() {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ title, author }), body: JSON.stringify({ title, author, asin }),
}); });
const data = await response.json(); const data = await response.json();
+30
View File
@@ -805,6 +805,36 @@ export class AudibleService {
return totalMinutes > 0 ? totalMinutes : undefined; return totalMinutes > 0 ? totalMinutes : undefined;
} }
/**
* Get runtime (in minutes) for an audiobook by ASIN
* Lightweight method for size validation during search
* Returns null if not found or error
*/
async getRuntime(asin: string): Promise<number | null> {
try {
// Use Audnexus API for fast, reliable runtime data
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
params: { region: audnexusRegion },
timeout: 5000, // Quick timeout for search performance
headers: { 'User-Agent': 'ReadMeABook/1.0' },
});
const runtimeMin = response.data?.runtimeLengthMin;
if (runtimeMin) {
return parseInt(runtimeMin);
}
return null;
} catch (error: any) {
if (error.response?.status !== 404) {
logger.debug(`Runtime fetch failed for ASIN ${asin}: ${error.message}`);
}
return null;
}
}
/** /**
* Add delay between requests to respect rate limits * Add delay between requests to respect rate limits
*/ */
+20 -2
View File
@@ -12,12 +12,18 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Prowlarr'); const logger = RMABLogger.create('Prowlarr');
export interface SearchFilters { export interface SearchFilters {
category?: number; category?: number; // Deprecated: use categories instead
categories?: number[]; // Array of category IDs to search
minSeeders?: number; minSeeders?: number;
maxResults?: number; maxResults?: number;
indexerIds?: number[]; indexerIds?: number[];
} }
export interface IndexerCategory {
id: number;
name: string;
}
export interface Indexer { export interface Indexer {
id: number; id: number;
name: string; name: string;
@@ -26,6 +32,7 @@ export interface Indexer {
priority: number; priority: number;
capabilities?: { capabilities?: {
supportsRss?: boolean; supportsRss?: boolean;
categories?: IndexerCategory[];
}; };
fields?: Array<{ fields?: Array<{
name: string; name: string;
@@ -119,12 +126,23 @@ export class ProwlarrService {
const configService = getConfigService(); const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent'; const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Determine which categories to search
// Priority: filters.categories > filters.category > defaultCategory
let categoriesToSearch: number[];
if (filters?.categories && filters.categories.length > 0) {
categoriesToSearch = filters.categories;
} else if (filters?.category) {
categoriesToSearch = [filters.category];
} else {
categoriesToSearch = [this.defaultCategory];
}
const params: Record<string, any> = { const params: Record<string, any> = {
query, query,
type: 'search', type: 'search',
limit: 100, // Maximum results to return from Prowlarr limit: 100, // Maximum results to return from Prowlarr
extended: 1, // Enable searching in tags, labels, and metadata extended: 1, // Enable searching in tags, labels, and metadata
categories: filters?.category?.toString() || this.defaultCategory.toString(), // 3030 = Audiobooks (standard Newznab category) categories: categoriesToSearch, // Will be serialized as categories=3030&categories=3040 etc
}; };
// Filter by specific indexers if provided // Filter by specific indexers if provided
@@ -100,6 +100,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
id: audiobook.id, id: audiobook.id,
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
}); });
matched++; matched++;
logger.info(`Triggered search job for request ${request.id}`); logger.info(`Triggered search job for request ${request.id}`);
@@ -133,22 +133,22 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
} }
} }
// Check for downloaded requests to match // Check for all non-terminal requests to match
const downloadedRequests = await prisma.request.findMany({ const matchableRequests = await prisma.request.findMany({
where: { where: {
status: 'downloaded', status: { notIn: ['available', 'cancelled'] },
deletedAt: null, deletedAt: null,
}, },
include: { audiobook: true }, include: { audiobook: true },
take: 50, take: 100,
}); });
if (downloadedRequests.length > 0) { if (matchableRequests.length > 0) {
logger.info(`Checking ${downloadedRequests.length} downloaded requests for matches`); logger.info(`Checking ${matchableRequests.length} matchable requests for matches (all non-terminal statuses)`);
const { findPlexMatch } = await import('../utils/audiobook-matcher'); const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) { for (const request of matchableRequests) {
try { try {
const audiobook = request.audiobook; const audiobook = request.audiobook;
const match = await findPlexMatch({ const match = await findPlexMatch({
@@ -159,7 +159,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}); });
if (match) { if (match) {
logger.info(`Match found: "${audiobook.title}" → "${match.title}"`); const originalStatus = request.status;
logger.info(
`Match found: "${audiobook.title}" → "${match.title}"` +
(originalStatus !== 'downloaded' ? ` (was '${originalStatus}')` : '')
);
// Update audiobook with matched library item ID // Update audiobook with matched library item ID
const updateData: any = { updatedAt: new Date() }; const updateData: any = { updatedAt: new Date() };
@@ -177,7 +181,15 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
await prisma.request.update({ await prisma.request.update({
where: { id: request.id }, where: { id: request.id },
data: { status: 'available', completedAt: new Date(), updatedAt: new Date() }, data: {
status: 'available',
completedAt: new Date(),
errorMessage: null,
searchAttempts: 0,
downloadAttempts: 0,
importAttempts: 0,
updatedAt: new Date(),
},
}); });
matchedDownloads++; matchedDownloads++;
@@ -198,7 +210,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
} }
} }
logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`); logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched requests`);
return { return {
success: true, success: true,
@@ -53,6 +53,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
id: request.audiobook.id, id: request.audiobook.id,
title: request.audiobook.title, title: request.audiobook.title,
author: request.audiobook.author, author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
}); });
triggered++; triggered++;
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`); logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
+18 -10
View File
@@ -316,23 +316,23 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
logger.info(`No orphaned audiobooks found`); logger.info(`No orphaned audiobooks found`);
} }
// 6. Match downloaded requests against library // 6. Match all non-terminal requests against library
logger.info(`Checking for downloaded requests to match...`); logger.info(`Checking for matchable requests...`);
const downloadedRequests = await prisma.request.findMany({ const matchableRequests = await prisma.request.findMany({
where: { where: {
status: 'downloaded', status: { notIn: ['available', 'cancelled'] },
deletedAt: null, deletedAt: null,
}, },
include: { audiobook: true }, include: { audiobook: true },
take: 50, // Limit to prevent overwhelming take: 100, // Increased from 50 to handle more eligible requests
}); });
logger.info(`Found ${downloadedRequests.length} downloaded requests to match`); logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
let matchedCount = 0; let matchedCount = 0;
const { findPlexMatch } = await import('../utils/audiobook-matcher'); const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) { for (const request of matchableRequests) {
try { try {
const audiobook = request.audiobook; const audiobook = request.audiobook;
@@ -346,7 +346,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
}); });
if (match) { if (match) {
logger.info(`Match found! "${audiobook.title}" -> "${match.title}"`); const originalStatus = request.status;
logger.info(
`Match found! "${audiobook.title}" -> "${match.title}"` +
(originalStatus !== 'downloaded' ? ` (was '${originalStatus}')` : '')
);
// Update audiobook with matched library item ID (plexGuid or abs_item_id) // Update audiobook with matched library item ID (plexGuid or abs_item_id)
const updateData: any = { updatedAt: new Date() }; const updateData: any = { updatedAt: new Date() };
@@ -362,12 +366,16 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
data: updateData, data: updateData,
}); });
// Update request to available // Update request to available and clear any error state
await prisma.request.update({ await prisma.request.update({
where: { id: request.id }, where: { id: request.id },
data: { data: {
status: 'available', status: 'available',
completedAt: new Date(), completedAt: new Date(),
errorMessage: null, // Clear any error state
searchAttempts: 0, // Reset retry counters
downloadAttempts: 0,
importAttempts: 0,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
@@ -389,7 +397,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
} }
} }
logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, { logger.info(`Matched ${matchedCount}/${matchableRequests.length} requests`, {
totalScanned: libraryItems.length, totalScanned: libraryItems.length,
newCount, newCount,
updatedCount, updatedCount,
+71 -12
View File
@@ -7,6 +7,7 @@ import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue
import { prisma } from '../db'; import { prisma } from '../db';
import { getProwlarrService } from '../integrations/prowlarr.service'; import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm'; import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
/** /**
@@ -41,9 +42,8 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
} }
const indexersConfig = JSON.parse(indexersConfigStr); const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) { if (indexersConfig.length === 0) {
throw new Error('No indexers enabled. Please enable at least one indexer in settings.'); throw new Error('No indexers enabled. Please enable at least one indexer in settings.');
} }
@@ -56,7 +56,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const flagConfigStr = await configService.get('indexer_flag_config'); const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`); // 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 ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
logger.info(`Group ${index + 1}: ${getGroupDescription(group)}`);
});
// Get Prowlarr service // Get Prowlarr service
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
@@ -66,15 +75,31 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching for: "${searchQuery}"`); logger.info(`Searching for: "${searchQuery}"`);
// Search indexers - ONLY enabled ones // Search Prowlarr for each group and combine results
const searchResults = await prowlarr.search(searchQuery, { const allResults = [];
category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 100, // Increased limit for broader search
indexerIds: enabledIndexerIds, // Filter by enabled indexers
});
logger.info(`Found ${searchResults.length} raw results`); for (let i = 0; i < groups.length; i++) {
const group = groups[i];
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.search(searchQuery, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 100, // Limit per group
});
logger.info(`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 searchResults = allResults;
logger.info(`Found ${searchResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (searchResults.length === 0) { if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing // No results found - queue for re-search instead of failing
@@ -97,15 +122,45 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}; };
} }
// Fetch runtime from Audnexus if ASIN available (for size-based scoring/filtering)
let durationMinutes: number | undefined;
if (audiobook.asin) {
const { getAudibleService } = await import('../integrations/audible.service');
const audibleService = getAudibleService();
const runtime = await audibleService.getRuntime(audiobook.asin);
if (runtime) {
durationMinutes = runtime;
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${audiobook.asin}`);
} else {
logger.debug(`No runtime found for ASIN ${audiobook.asin}`);
}
}
// Log filter info
const sizeMBThreshold = 20;
const preFilterCount = searchResults.length;
const belowThreshold = searchResults.filter(r => (r.size / (1024 * 1024)) < sizeMBThreshold);
if (belowThreshold.length > 0) {
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
}
// Get ranking algorithm // Get ranking algorithm
const ranker = getRankingAlgorithm(); const ranker = getRankingAlgorithm();
// Rank results with indexer priorities and flag configs // Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
const rankedResults = ranker.rankTorrents(searchResults, { const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
durationMinutes,
}, indexerPriorities, flagConfigs); }, indexerPriorities, flagConfigs);
// Log filter results
const postFilterCount = rankedResults.length;
if (postFilterCount < preFilterCount) {
logger.info(`Filtered out ${preFilterCount - postFilterCount} results < ${sizeMBThreshold} MB`);
}
// Dual threshold filtering: // Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum) // 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses) // 2. Final score must be >= 50 (not disqualified by negative bonuses)
@@ -155,12 +210,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`--------------------------------------------------------`); logger.info(`--------------------------------------------------------`);
for (let i = 0; i < top3.length; i++) { for (let i = 0; i < top3.length; i++) {
const result = top3[i]; const result = top3[i];
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
logger.info(`${i + 1}. "${result.title}"`); logger.info(`${i + 1}. "${result.title}"`);
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`); logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
logger.info(``); logger.info(``);
logger.info(` Base Score: ${result.score.toFixed(1)}/100`); logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`); logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
logger.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`); logger.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`);
logger.info(` - Size Quality: ${durationMinutes ? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min, ${durationMinutes} min runtime)` : 'N/A (no runtime data)'}`);
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`); logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
logger.info(``); logger.info(``);
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`); logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
+2 -1
View File
@@ -37,6 +37,7 @@ export interface SearchIndexersPayload extends JobPayload {
id: string; id: string;
title: string; title: string;
author: string; author: string;
asin?: string; // Optional ASIN for runtime-based size scoring
}; };
} }
@@ -441,7 +442,7 @@ export class JobQueueService {
/** /**
* Add search indexers job * Add search indexers job
*/ */
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string }): Promise<string> { async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise<string> {
return await this.addJob( return await this.addJob(
'search_indexers', 'search_indexers',
{ {
+101
View File
@@ -0,0 +1,101 @@
/**
* Utility: Indexer Grouping by Categories
* Documentation: documentation/phase3/prowlarr.md
*
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
*/
export interface IndexerConfig {
id: number;
name: string;
priority?: number;
categories?: number[];
[key: string]: any; // Allow other properties
}
export interface IndexerGroup {
categories: number[];
indexerIds: number[];
indexers: IndexerConfig[];
}
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
*
* @param indexers - Array of indexer configurations
* @returns Array of groups, each containing indexers with matching categories
*
* @example
* const indexers = [
* { id: 1, categories: [3030] },
* { id: 2, categories: [3030] },
* { id: 3, categories: [3030, 3010] },
* ];
*
* const groups = groupIndexersByCategories(indexers);
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*/
export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
const groupMap = new Map<string, IndexerConfig[]>();
for (const indexer of indexers) {
// Get categories, default to [3030] (audiobooks) if not specified
const categories = indexer.categories && indexer.categories.length > 0
? indexer.categories
: [3030];
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
const indexerIds = indexersInGroup.map(idx => idx.id);
groups.push({
categories,
indexerIds,
indexers: indexersInGroup,
});
}
return groups;
}
/**
* Get a human-readable description of an indexer group.
* Useful for logging and debugging.
*
* @param group - The indexer group
* @returns Description string
*
* @example
* const description = getGroupDescription(group);
* // "3 indexers (IDs: 1, 2, 5) searching categories [3030, 3010]"
*/
export function getGroupDescription(group: IndexerGroup): string {
const indexerCount = group.indexerIds.length;
const indexerNames = group.indexers.map(idx => idx.name).join(', ');
const categoriesStr = group.categories.join(', ');
return `${indexerCount} indexer${indexerCount > 1 ? 's' : ''} (${indexerNames}) with categories [${categoriesStr}]`;
}
+87 -17
View File
@@ -45,6 +45,7 @@ export interface BonusModifier {
export interface ScoreBreakdown { export interface ScoreBreakdown {
formatScore: number; formatScore: number;
sizeScore: number;
seederScore: number; seederScore: number;
matchScore: number; matchScore: number;
totalScore: number; totalScore: number;
@@ -64,7 +65,7 @@ export class RankingAlgorithm {
/** /**
* Rank all torrents and return sorted by finalScore (best first) * Rank all torrents and return sorted by finalScore (best first)
* @param torrents - Array of torrent results to rank * @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details for matching * @param audiobook - Audiobook request details for matching (includes durationMinutes for size scoring)
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10 * @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers * @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
*/ */
@@ -74,13 +75,20 @@ export class RankingAlgorithm {
indexerPriorities?: Map<number, number>, indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[] flagConfigs?: IndexerFlagConfig[]
): RankedTorrent[] { ): RankedTorrent[] {
const ranked = torrents.map((torrent) => { // Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => {
const sizeMB = torrent.size / (1024 * 1024);
return sizeMB >= 20;
});
const ranked = filteredTorrents.map((torrent) => {
// Calculate base scores (0-100) // Calculate base scores (0-100)
const formatScore = this.scoreFormat(torrent); const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders); const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook); const matchScore = this.scoreMatch(torrent, audiobook);
const baseScore = formatScore + seederScore + matchScore; const baseScore = formatScore + sizeScore + seederScore + matchScore;
// Calculate bonus modifiers // Calculate bonus modifiers
const bonusModifiers: BonusModifier[] = []; const bonusModifiers: BonusModifier[] = [];
@@ -136,16 +144,18 @@ export class RankingAlgorithm {
rank: 0, // Will be assigned after sorting rank: 0, // Will be assigned after sorting
breakdown: { breakdown: {
formatScore, formatScore,
sizeScore,
seederScore, seederScore,
matchScore, matchScore,
totalScore: baseScore, totalScore: baseScore,
notes: this.generateNotes(torrent, { notes: this.generateNotes(torrent, {
formatScore, formatScore,
sizeScore,
seederScore, seederScore,
matchScore, matchScore,
totalScore: baseScore, totalScore: baseScore,
notes: [], notes: [],
}), }, audiobook.durationMinutes),
}, },
}; };
}); });
@@ -176,48 +186,89 @@ export class RankingAlgorithm {
audiobook: AudiobookRequest audiobook: AudiobookRequest
): ScoreBreakdown { ): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent); const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders); const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook); const matchScore = this.scoreMatch(torrent, audiobook);
const totalScore = formatScore + seederScore + matchScore; const totalScore = formatScore + sizeScore + seederScore + matchScore;
return { return {
formatScore, formatScore,
sizeScore,
seederScore, seederScore,
matchScore, matchScore,
totalScore, totalScore,
notes: this.generateNotes(torrent, { notes: this.generateNotes(torrent, {
formatScore, formatScore,
sizeScore,
seederScore, seederScore,
matchScore, matchScore,
totalScore, totalScore,
notes: [], notes: [],
}), }, audiobook.durationMinutes),
}; };
} }
/** /**
* Score format quality (25 points max) * Score format quality (10 points max)
* M4B with chapters: 25 pts * Reduced from 25 to make room for data-driven size scoring
* M4B without chapters: 22 pts * M4B with chapters: 10 pts
* M4A: 16 pts * M4B without chapters: 9 pts
* MP3: 10 pts * M4A: 6 pts
* Other: 3 pts * MP3: 4 pts
* Other: 1 pt
*/ */
private scoreFormat(torrent: TorrentResult): number { private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent); const format = this.detectFormat(torrent);
switch (format) { switch (format) {
case 'M4B': case 'M4B':
return torrent.hasChapters !== false ? 25 : 22; return torrent.hasChapters !== false ? 10 : 9;
case 'M4A': case 'M4A':
return 16; return 6;
case 'MP3': case 'MP3':
return 10; return 4;
default: default:
return 3; return 1;
} }
} }
/**
* Score file size quality (15 points max)
* Uses book runtime and file size to validate correct file type
* Filters out ebooks and ranks audiobook quality
*
* @param torrent - Torrent result with size in bytes
* @param runtimeMinutes - Book runtime in minutes from Audnexus
* @returns 0-15 points based on MB/min ratio
*
* Algorithm:
* - >= 1.0 MB/min 15/15 points (high quality baseline)
* - Linear scaling below 1.0 MB/min
* - 0 points if no runtime data (graceful degradation)
*
* Note: Files < 20 MB are pre-filtered in rankTorrents()
*/
private scoreSize(torrent: TorrentResult, runtimeMinutes: number | undefined): number {
// Graceful degradation: no runtime data = no size scoring
if (!runtimeMinutes || runtimeMinutes === 0) {
return 0;
}
const sizeMB = torrent.size / (1024 * 1024);
const mbPerMin = sizeMB / runtimeMinutes;
// High quality baseline: 1.0 MB/min or higher gets full points
// This is ~64 kbps MP3 equivalent
if (mbPerMin >= 1.0) {
return 15;
}
// Linear scaling below baseline
// 0.5 MB/min = 7.5 points
// 0.3 MB/min = 4.5 points
return mbPerMin * 15;
}
/** /**
* Score seeder count (15 points max) * Score seeder count (15 points max)
* Logarithmic scaling: * Logarithmic scaling:
@@ -429,7 +480,8 @@ export class RankingAlgorithm {
*/ */
private generateNotes( private generateNotes(
torrent: TorrentResult, torrent: TorrentResult,
breakdown: ScoreBreakdown breakdown: ScoreBreakdown,
runtimeMinutes?: number
): string[] { ): string[] {
const notes: string[] = []; const notes: string[] = [];
@@ -448,6 +500,24 @@ export class RankingAlgorithm {
notes.push('Unknown or uncommon format'); notes.push('Unknown or uncommon format');
} }
// Size notes
if (runtimeMinutes && runtimeMinutes > 0) {
const sizeMB = torrent.size / (1024 * 1024);
const mbPerMin = sizeMB / runtimeMinutes;
if (mbPerMin >= 1.5) {
notes.push('✓ Premium quality (high bitrate)');
} else if (mbPerMin >= 1.0) {
notes.push('✓ High quality');
} else if (mbPerMin >= 0.5) {
notes.push('Standard quality');
} else if (mbPerMin >= 0.3) {
notes.push('⚠️ Low quality (low bitrate)');
} else {
notes.push('⚠️ Very low quality - may be ebook');
}
}
// Seeder notes (skip for NZB/Usenet results which don't have seeders) // Seeder notes (skip for NZB/Usenet results which don't have seeders)
if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) { if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
if (torrent.seeders === 0) { if (torrent.seeders === 0) {
+78
View File
@@ -0,0 +1,78 @@
/**
* Predefined Torrent Category Tree
* Documentation: documentation/phase3/prowlarr.md
*/
export interface TorrentCategory {
id: number;
name: string;
children?: TorrentCategory[];
}
export const TORRENT_CATEGORIES: TorrentCategory[] = [
{
id: 3000,
name: 'Audio',
children: [
{ id: 3010, name: 'MP3' },
{ id: 3030, name: 'Audiobook' },
{ id: 3040, name: 'Lossless' },
{ id: 3050, name: 'Other' },
{ id: 3060, name: 'Foreign' },
],
},
{
id: 7000,
name: 'Books',
children: [
{ id: 7020, name: 'EBook' },
{ id: 7050, name: 'Other' },
{ id: 7060, name: 'Foreign' },
],
},
{
id: 8000,
name: 'Other',
},
];
export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook
/**
* Get all child IDs for a parent category
*/
export function getChildIds(parentId: number): number[] {
const parent = TORRENT_CATEGORIES.find((cat) => cat.id === parentId);
return parent?.children?.map((child) => child.id) || [];
}
/**
* Get parent ID for a child category
*/
export function getParentId(childId: number): number | null {
for (const parent of TORRENT_CATEGORIES) {
if (parent.children?.some((child) => child.id === childId)) {
return parent.id;
}
}
return null;
}
/**
* Check if all children of a parent are selected
*/
export function areAllChildrenSelected(
parentId: number,
selectedIds: number[]
): boolean {
const childIds = getChildIds(parentId);
return childIds.length > 0 && childIds.every((id) => selectedIds.includes(id));
}
/**
* Check if a category is a parent (has children)
*/
export function isParentCategory(categoryId: number): boolean {
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
return !!category?.children && category.children.length > 0;
}