diff --git a/documentation/backend/services/jobs.md b/documentation/backend/services/jobs.md
index 07b9da3..3198d29 100644
--- a/documentation/backend/services/jobs.md
+++ b/documentation/backend/services/jobs.md
@@ -29,8 +29,8 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
1. **search_indexers** - Search Prowlarr for torrents
2. **monitor_download** - Poll progress (10s intervals)
3. **organize_files** - Move to media library, set status to 'downloaded'
-4. **scan_plex** - Full scan of Plex library, match 'downloaded' requests
-5. **plex_recently_added_check** - Lightweight polling of recently added items (top 10)
+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, match all non-terminal requests
6. **match_plex** - Fuzzy match to Plex item (deprecated - now handled by scan_plex)
## 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
**scan_plex:**
-- Scans Plex library and populates plex_library table
-- After scan, checks for requests with status 'downloaded'
-- Fuzzy matches downloaded requests against Plex library (70% threshold)
-- Matched requests → 'available' status with plexGuid linked
+- Full library scan (Plex/Audiobookshelf) and populates plex_library table
+- Checks all non-terminal request statuses for matches (excludes: available, cancelled)
+- Fuzzy matches via ASIN/ISBN/title/author (70% threshold)
+- 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
diff --git a/documentation/integrations/plex.md b/documentation/integrations/plex.md
index 2cdd2c0..85665a5 100644
--- a/documentation/integrations/plex.md
+++ b/documentation/integrations/plex.md
@@ -80,6 +80,40 @@ API Docs: `/PlexMediaServerAPIDocs.json`
**Benefits:** Lightweight polling for new items + comprehensive matching for downloaded requests
**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
```typescript
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
index e7f0935..7913d17 100644
--- a/src/app/admin/settings/page.tsx
+++ b/src/app/admin/settings/page.tsx
@@ -12,6 +12,7 @@ import Link from 'next/link';
import { fetchWithAuth } from '@/lib/utils/api';
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
+import { IndexersTab } from './tabs/IndexersTab';
interface PlexLibrary {
id: string;
@@ -28,6 +29,7 @@ interface IndexerConfig {
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
+ categories?: number[];
supportsRss?: boolean;
}
@@ -115,6 +117,7 @@ export default function AdminSettings() {
const [plexLibraries, setPlexLibraries] = useState([]);
const [absLibraries, setAbsLibraries] = useState([]);
const [indexers, setIndexers] = useState([]);
+ const [configuredIndexers, setConfiguredIndexers] = useState>([]);
const [flagConfigs, setFlagConfigs] = useState([]);
const [pendingUsers, setPendingUsers] = useState([]);
const [isLocalAdmin, setIsLocalAdmin] = useState(false);
@@ -310,6 +313,19 @@ export default function AdminSettings() {
const data = await response.json();
setIndexers(data.indexers || []);
setFlagConfigs(data.flagConfigs || []);
+
+ // Extract configured indexers (enabled ones) for the new IndexerManagement component
+ const configured = (data.indexers || [])
+ .filter((idx: IndexerConfig) => idx.enabled)
+ .map((idx: IndexerConfig) => ({
+ id: idx.id,
+ name: idx.name,
+ priority: idx.priority,
+ seedingTimeMinutes: idx.seedingTimeMinutes,
+ rssEnabled: idx.rssEnabled,
+ categories: idx.categories || [3030], // Include categories, default to audiobooks
+ }));
+ setConfiguredIndexers(configured);
} else {
console.error('Failed to fetch indexers:', response.status);
// Don't show error on initial load, only if user explicitly tries to load
@@ -935,17 +951,21 @@ export default function AdminSettings() {
throw new Error('Failed to save Prowlarr settings');
}
- // Save indexer configuration and flag configs if indexers are loaded
- if (indexers.length > 0) {
- const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ indexers, flagConfigs }),
- });
+ // Save indexer configuration and flag configs
+ // Convert configured indexers to the format expected by the API (with enabled: true)
+ const indexersForSave = configuredIndexers.map((idx) => ({
+ ...idx,
+ enabled: true,
+ }));
- if (!indexersResponse.ok) {
- throw new Error('Failed to save indexer configuration');
- }
+ const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ indexers: indexersForSave, flagConfigs }),
+ });
+
+ if (!indexersResponse.ok) {
+ throw new Error('Failed to save indexer configuration');
}
break;
@@ -1441,273 +1461,17 @@ export default function AdminSettings() {
{/* Prowlarr/Indexers Tab */}
{activeTab === 'prowlarr' && (
-
-
-
- Indexer Configuration
-
-
- Configure your Prowlarr connection and select which indexers to use with priority and seeding time.
-
-
-
-
-
- Prowlarr Server URL
-
- {
- 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"
- />
-
-
-
-
- Prowlarr API Key
-
-
{
- 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"
- />
-
- Found in Prowlarr Settings → General → Security → API Key
-
-
-
-
-
- {(() => {
- if (originalSettings &&
- settings.prowlarr.url === originalSettings.prowlarr.url &&
- settings.prowlarr.apiKey === originalSettings.prowlarr.apiKey) {
- return 'Refresh Indexers';
- }
- return 'Test Connection';
- })()}
-
- {testResults.prowlarr && (
-
- {testResults.prowlarr.message}
-
- )}
-
-
-
-
-
- Indexer Configuration
-
- {indexers.length > 0 && !loadingIndexers && (
-
- {indexers.filter(idx => idx.enabled).length} enabled
-
- )}
-
- {loadingIndexers ? (
-
-
-
Loading indexers...
-
- ) : indexers.length > 0 ? (
-
- {indexers.map((indexer) => (
-
-
-
{
- 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"
- />
-
-
-
- {indexer.name}
-
-
- {indexer.protocol}
-
-
-
-
-
- Priority (1-25)
-
-
{
- 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"
- />
-
Higher = preferred
-
-
-
- Seeding Time (minutes)
-
-
{
- 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"
- />
-
0 = unlimited
-
-
-
- RSS Monitoring
-
-
- {
- 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"
- />
-
-
Auto check for new releases
-
-
-
-
-
- ))}
-
- ) : (
-
-
No indexers configured.
-
- {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.'}
-
-
- )}
-
-
- {/* Flag Configuration Section */}
-
-
-
- Indexer Flag Configuration (Optional)
-
-
- Configure score bonuses or penalties for indexer flags like "Freeleech".
- These modifiers apply universally across all indexers and affect final torrent ranking.
-
-
-
- {flagConfigs.length > 0 && (
-
- {flagConfigs.map((config, index) => (
- {
- const newConfigs = [...flagConfigs];
- newConfigs[index] = updated;
- setFlagConfigs(newConfigs);
- }}
- onRemove={() => {
- setFlagConfigs(flagConfigs.filter((_, i) => i !== index));
- }}
- />
- ))}
-
- )}
-
-
{
- setFlagConfigs([...flagConfigs, { name: '', modifier: 0 }]);
- }}
- variant="outline"
- size="sm"
- >
- + Add Flag Rule
-
-
- {flagConfigs.length === 0 && (
-
- No flag rules configured. Flag bonuses/penalties are optional.
-
- )}
-
-
+
)}
{/* Download Client Tab */}
diff --git a/src/app/admin/settings/tabs/IndexersTab.tsx b/src/app/admin/settings/tabs/IndexersTab.tsx
new file mode 100644
index 0000000..7d4e6e0
--- /dev/null
+++ b/src/app/admin/settings/tabs/IndexersTab.tsx
@@ -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 (
+
+
+
+ Indexer Configuration
+
+
+ Configure your Prowlarr connection and manage which indexers to use with priority and seeding time.
+
+
+
+
+
+ Prowlarr Server URL
+
+ {
+ 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"
+ />
+
+
+
+
+ Prowlarr API Key
+
+
{
+ 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"
+ />
+
+ Found in Prowlarr Settings → General → Security → API Key
+
+
+
+
+
+
+
+ {/* Flag Configuration Section */}
+
+
+
+ Indexer Flag Configuration (Optional)
+
+
+ Configure score bonuses or penalties for indexer flags like "Freeleech".
+ These modifiers apply universally across all indexers and affect final torrent ranking.
+
+
+
+ {flagConfigs.length > 0 && (
+
+ {flagConfigs.map((config, index) => (
+ {
+ const newConfigs = [...flagConfigs];
+ newConfigs[index] = updated;
+ onFlagConfigsChange(newConfigs);
+ }}
+ onRemove={() => {
+ onFlagConfigsChange(flagConfigs.filter((_, i) => i !== index));
+ }}
+ />
+ ))}
+
+ )}
+
+
{
+ onFlagConfigsChange([...flagConfigs, { name: '', modifier: 0 }]);
+ }}
+ variant="outline"
+ size="sm"
+ >
+ + Add Flag Rule
+
+
+ {flagConfigs.length === 0 && (
+
+ No flag rules configured. Flag bonuses/penalties are optional.
+
+ )}
+
+
+ );
+}
diff --git a/src/app/api/admin/settings/prowlarr/indexers/route.ts b/src/app/api/admin/settings/prowlarr/indexers/route.ts
index 050e667..1f83209 100644
--- a/src/app/api/admin/settings/prowlarr/indexers/route.ts
+++ b/src/app/api/admin/settings/prowlarr/indexers/route.ts
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
priority: number;
seedingTimeMinutes: number;
rssEnabled?: boolean;
+ categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
}
/**
@@ -48,16 +49,19 @@ export async function GET(request: NextRequest) {
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
+ const isAdded = !!saved;
return {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
privacy: indexer.privacy,
- enabled: !!saved, // Enabled if in saved list
+ enabled: isAdded, // Enabled if in saved list
+ isAdded, // Explicit flag for UI (new card-based interface)
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
+ categories: saved?.categories || [3030], // Default to audiobooks category
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
});
@@ -101,6 +105,7 @@ export async function PUT(request: NextRequest) {
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
+ categories: indexer.categories || [3030], // Default to audiobooks if not specified
}));
// Save to configuration (matches wizard format)
diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts
index 47275a5..2045d8f 100644
--- a/src/app/api/audiobooks/search-torrents/route.ts
+++ b/src/app/api/audiobooks/search-torrents/route.ts
@@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
+import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
@@ -17,6 +18,7 @@ const logger = RMABLogger.create('API.AudiobookSearch');
const SearchSchema = z.object({
title: z.string(),
author: z.string(),
+ asin: z.string().optional(), // Optional ASIN for runtime-based size scoring
});
/**
@@ -34,7 +36,7 @@ export async function POST(request: NextRequest) {
}
const body = await req.json();
- const { title, author } = SearchSchema.parse(body);
+ const { title, author, asin } = SearchSchema.parse(body);
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
@@ -49,9 +51,8 @@ export async function POST(request: NextRequest) {
}
const indexersConfig = JSON.parse(indexersConfigStr);
- const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
- if (enabledIndexerIds.length === 0) {
+ if (indexersConfig.length === 0) {
return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 }
@@ -67,18 +68,43 @@ export async function POST(request: NextRequest) {
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
- // Search Prowlarr for torrents - ONLY enabled indexers
- const prowlarr = await getProwlarrService();
- const searchQuery = title; // Title only - cast wide net
+ // Group indexers by their category configuration
+ // This minimizes API calls while ensuring each indexer only searches its configured categories
+ const groups = groupIndexersByCategories(indexersConfig);
- logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
+ logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
- const results = await prowlarr.search(searchQuery, {
- indexerIds: enabledIndexerIds,
- maxResults: 100, // Increased limit for broader search
+ // Log each group for transparency
+ groups.forEach((group, index) => {
+ logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
});
- logger.debug(`Found ${results.length} raw results`, { title, author });
+ // Search Prowlarr for each group and combine results
+ const prowlarr = await getProwlarrService();
+ const searchQuery = title; // Title only - cast wide net
+ const allResults = [];
+
+ for (let i = 0; i < groups.length; i++) {
+ const group = groups[i];
+ logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
+
+ try {
+ const groupResults = await prowlarr.search(searchQuery, {
+ categories: group.categories,
+ indexerIds: group.indexerIds,
+ maxResults: 100, // Limit per group
+ });
+
+ logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
+ allResults.push(...groupResults);
+ } catch (error) {
+ logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ // Continue with other groups even if one fails
+ }
+ }
+
+ const results = allResults;
+ logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (results.length === 0) {
return NextResponse.json({
@@ -88,8 +114,37 @@ export async function POST(request: NextRequest) {
});
}
+ // Fetch runtime from Audnexus if ASIN provided (for size-based scoring/filtering)
+ let durationMinutes: number | undefined;
+ if (asin) {
+ const { getAudibleService } = await import('@/lib/integrations/audible.service');
+ const audibleService = getAudibleService();
+ const runtime = await audibleService.getRuntime(asin);
+ if (runtime) {
+ durationMinutes = runtime;
+ logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${asin}`);
+ } else {
+ logger.debug(`No runtime found for ASIN ${asin}`);
+ }
+ }
+
+ // Log filter info
+ const sizeMBThreshold = 20;
+ const preFilterCount = results.length;
+ const belowThreshold = results.filter(r => (r.size / (1024 * 1024)) < sizeMBThreshold);
+ if (belowThreshold.length > 0) {
+ logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
+ }
+
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
- const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
+ // Note: rankTorrents now filters out results < 20 MB internally
+ const rankedResults = rankTorrents(results, { title, author, durationMinutes }, indexerPriorities, flagConfigs);
+
+ // Log filter results
+ const postFilterCount = rankedResults.length;
+ if (postFilterCount < preFilterCount) {
+ logger.info(`Filtered out ${preFilterCount - postFilterCount} results < ${sizeMBThreshold} MB`);
+ }
// No threshold filtering - show all results like interactive search
// User can see scores and make their own decision
@@ -103,12 +158,18 @@ export async function POST(request: NextRequest) {
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => {
+ const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
+ const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
+
logger.debug(`${index + 1}. "${result.title}"`, {
indexer: result.indexer,
indexerId: result.indexerId,
baseScore: `${result.score.toFixed(1)}/100`,
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
- formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
+ formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
+ sizeScore: durationMinutes
+ ? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
+ : 'N/A (no runtime)',
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts
index 9e2dd4d..b83f2b9 100644
--- a/src/app/api/bookdate/swipe/route.ts
+++ b/src/app/api/bookdate/swipe/route.ts
@@ -109,6 +109,7 @@ async function handler(req: AuthenticatedRequest) {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
+ asin: audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search job for request ${newRequest.id}`);
diff --git a/src/app/api/requests/[id]/manual-search/route.ts b/src/app/api/requests/[id]/manual-search/route.ts
index 6147fa4..d155356 100644
--- a/src/app/api/requests/[id]/manual-search/route.ts
+++ b/src/app/api/requests/[id]/manual-search/route.ts
@@ -70,6 +70,7 @@ export async function POST(
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
+ asin: requestRecord.audiobook.audibleAsin || undefined,
});
// Update request status
diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts
index 2bee262..ec73749 100644
--- a/src/app/api/requests/[id]/route.ts
+++ b/src/app/api/requests/[id]/route.ts
@@ -274,6 +274,7 @@ export async function PATCH(
id: requestWithData.audiobook.id,
title: requestWithData.audiobook.title,
author: requestWithData.audiobook.author,
+ asin: requestWithData.audiobook.audibleAsin || undefined,
});
updated = await prisma.request.update({
diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts
index f79f4d0..1f174ab 100644
--- a/src/app/api/requests/route.ts
+++ b/src/app/api/requests/route.ts
@@ -176,6 +176,7 @@ export async function POST(request: NextRequest) {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
+ asin: audiobookRecord.audibleAsin || undefined,
});
}
diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx
index cc2403c..779100f 100644
--- a/src/app/setup/steps/ProwlarrStep.tsx
+++ b/src/app/setup/steps/ProwlarrStep.tsx
@@ -5,9 +5,10 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
+import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
interface ProwlarrStepProps {
prowlarrUrl: string;
@@ -17,19 +18,13 @@ interface ProwlarrStepProps {
onBack: () => void;
}
-interface IndexerInfo {
- id: number;
- name: string;
- protocol: string;
- supportsRss: boolean;
-}
-
interface SelectedIndexer {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
+ categories: number[];
}
export function ProwlarrStep({
@@ -39,141 +34,24 @@ export function ProwlarrStep({
onNext,
onBack,
}: ProwlarrStepProps) {
- const [testing, setTesting] = useState(false);
- const [testResult, setTestResult] = useState<{
- success: boolean;
- message: string;
- indexerCount?: number;
- } | null>(null);
- const [availableIndexers, setAvailableIndexers] = useState([]);
- const [selectedIndexers, setSelectedIndexers] = useState>({});
+ const [configuredIndexers, setConfiguredIndexers] = useState([]);
+ const [errorMessage, setErrorMessage] = useState(null);
- const testConnection = async () => {
- setTesting(true);
- setTestResult(null);
-
- try {
- const response = await fetch('/api/setup/test-prowlarr', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url: prowlarrUrl, apiKey: prowlarrApiKey }),
- });
-
- const data = await response.json();
-
- if (response.ok && data.success) {
- setTestResult({
- success: true,
- message: `Connected successfully! Found ${data.indexerCount || 0} configured indexers.`,
- indexerCount: data.indexerCount,
- });
- setAvailableIndexers(data.indexers || []);
-
- // Auto-select all indexers with default priority of 10, seeding time of 0 (unlimited), and RSS enabled if supported
- const autoSelected: Record = {};
- data.indexers.forEach((indexer: IndexerInfo) => {
- autoSelected[indexer.id] = {
- id: indexer.id,
- name: indexer.name,
- priority: 10,
- seedingTimeMinutes: 0,
- rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
- };
- });
- setSelectedIndexers(autoSelected);
- onUpdate('prowlarrIndexers', Object.values(autoSelected));
- } else {
- setTestResult({
- success: false,
- message: data.error || 'Connection failed',
- });
- }
- } catch (error) {
- setTestResult({
- success: false,
- message: error instanceof Error ? error.message : 'Connection test failed',
- });
- } finally {
- setTesting(false);
- }
- };
-
- const toggleIndexer = (indexer: IndexerInfo) => {
- setSelectedIndexers((prev) => {
- const newSelected = { ...prev };
- if (newSelected[indexer.id]) {
- delete newSelected[indexer.id];
- } else {
- newSelected[indexer.id] = {
- id: indexer.id,
- name: indexer.name,
- priority: 10, // Default priority
- seedingTimeMinutes: 0, // Default: unlimited seeding
- rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
- };
- }
- onUpdate('prowlarrIndexers', Object.values(newSelected));
- return newSelected;
- });
- };
-
- const updatePriority = (indexerId: number, priority: number) => {
- setSelectedIndexers((prev) => {
- const newSelected = { ...prev };
- if (newSelected[indexerId]) {
- newSelected[indexerId] = {
- ...newSelected[indexerId],
- priority: Math.max(1, Math.min(25, priority)), // Clamp between 1-25
- };
- }
- onUpdate('prowlarrIndexers', Object.values(newSelected));
- return newSelected;
- });
- };
-
- const updateSeedingTime = (indexerId: number, value: string) => {
- setSelectedIndexers((prev) => {
- const newSelected = { ...prev };
- if (newSelected[indexerId]) {
- const seedingTimeMinutes = value === '' ? 0 : parseInt(value);
- newSelected[indexerId] = {
- ...newSelected[indexerId],
- seedingTimeMinutes: isNaN(seedingTimeMinutes) ? 0 : Math.max(0, seedingTimeMinutes),
- };
- }
- onUpdate('prowlarrIndexers', Object.values(newSelected));
- return newSelected;
- });
- };
-
- const toggleRss = (indexerId: number) => {
- setSelectedIndexers((prev) => {
- const newSelected = { ...prev };
- if (newSelected[indexerId]) {
- newSelected[indexerId] = {
- ...newSelected[indexerId],
- rssEnabled: !newSelected[indexerId].rssEnabled,
- };
- }
- onUpdate('prowlarrIndexers', Object.values(newSelected));
- return newSelected;
- });
- };
+ // Sync configured indexers with parent
+ useEffect(() => {
+ onUpdate('prowlarrIndexers', configuredIndexers);
+ }, [configuredIndexers, onUpdate]);
const handleNext = () => {
- if (!testResult?.success) {
- setTestResult({
- success: false,
- message: 'Please test the connection before proceeding',
- });
+ setErrorMessage(null);
+
+ if (!prowlarrUrl || !prowlarrApiKey) {
+ setErrorMessage('Please enter Prowlarr URL and API key');
return;
}
- if (Object.keys(selectedIndexers).length === 0) {
- setTestResult({
- success: false,
- message: 'Please select at least one indexer',
- });
+ if (configuredIndexers.length === 0) {
+ setErrorMessage('Please add at least one indexer');
return;
}
@@ -222,207 +100,52 @@ export function ProwlarrStep({
-
- Test Connection
-
-
- {testResult && (
-
+ {errorMessage && (
+
- {testResult.success ? (
-
- ) : (
-
- )}
+
-
- {testResult.success ? 'Success' : 'Error'}
+
+ Error
-
- {testResult.message}
+
+ {errorMessage}
)}
- {/* Indexer Selection */}
- {availableIndexers.length > 0 && (
-
-
-
- Select Indexers & Configure (Priority: 1-25, Seeding Time, RSS)
-
-
- 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).
-
-
- {availableIndexers.map((indexer) => (
-
-
toggleIndexer(indexer)}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
- />
-
- {indexer.name}
-
- ({indexer.protocol})
-
-
- {selectedIndexers[indexer.id] && (
-
-
-
- Priority:
-
-
- 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"
- />
-
-
-
- Seeding (min):
-
-
- 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 = ∞"
- />
-
- {indexer.supportsRss && (
-
-
- RSS:
-
- toggleRss(indexer.id)}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
- />
-
- )}
-
- )}
-
- ))}
-
-
- Selected: {Object.keys(selectedIndexers).length} of {availableIndexers.length} indexers
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- About Prowlarr Indexers
-
-
- 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.
-
-
+ {/* Indexer Management Component */}
+
+
-
+ {/* Navigation Buttons */}
+
Back
- Next
+
+ Next
+
);
diff --git a/src/components/admin/indexers/AvailableIndexerRow.tsx b/src/components/admin/indexers/AvailableIndexerRow.tsx
new file mode 100644
index 0000000..5840d09
--- /dev/null
+++ b/src/components/admin/indexers/AvailableIndexerRow.tsx
@@ -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 (
+
+ {/* Indexer Info */}
+
+
+
+
+ {indexer.name}
+
+
+ {indexer.protocol}
+
+
+
+
+
+ {/* Action */}
+
+ {isAdded ? (
+
+ ) : (
+
+ Add
+
+ )}
+
+
+ );
+}
diff --git a/src/components/admin/indexers/CategoryTreeView.tsx b/src/components/admin/indexers/CategoryTreeView.tsx
new file mode 100644
index 0000000..98ec21c
--- /dev/null
+++ b/src/components/admin/indexers/CategoryTreeView.tsx
@@ -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 (
+
+ {TORRENT_CATEGORIES.map((category) => (
+
+ {/* Parent Category Header */}
+
+
+
+ {category.name}
+
+
+ [{category.id}]
+
+ {category.id === 3030 && (
+
+ Default
+
+ )}
+
+
{
+ if (isParentCategory(category.id)) {
+ handleParentToggle(category.id);
+ } else {
+ handleChildToggle(category.id);
+ }
+ }}
+ disabled={false}
+ />
+
+
+ {/* Child Categories */}
+ {category.children && category.children.length > 0 && (
+
+ {category.children.map((child) => (
+
+
+
+ {child.name}
+
+
+ [{child.id}]
+
+ {child.id === 3030 && (
+
+ Default
+
+ )}
+
+
handleChildToggle(child.id)}
+ disabled={isParentSelected(category.id)}
+ />
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+interface ToggleSwitchProps {
+ checked: boolean;
+ onChange: () => void;
+ disabled: boolean;
+}
+
+function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/indexers/DeleteConfirmModal.tsx b/src/components/admin/indexers/DeleteConfirmModal.tsx
new file mode 100644
index 0000000..48742a9
--- /dev/null
+++ b/src/components/admin/indexers/DeleteConfirmModal.tsx
@@ -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 (
+
+
+
+
+
+
+ Are you sure you want to remove {indexerName} ?
+
+
+ This indexer will no longer be used for searches. You can add it back later if needed.
+
+
+
+
+
+
+ Cancel
+
+
+ Remove Indexer
+
+
+
+
+ );
+}
diff --git a/src/components/admin/indexers/IndexerCard.tsx b/src/components/admin/indexers/IndexerCard.tsx
new file mode 100644
index 0000000..002d670
--- /dev/null
+++ b/src/components/admin/indexers/IndexerCard.tsx
@@ -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 (
+
+
+ {/* Indexer Info */}
+
+
+ {indexer.name}
+
+
+ {indexer.protocol}
+
+
+
+ {/* Action Buttons */}
+
+ {/* Edit Button */}
+
+
+
+
+
+
+ {/* Delete Button */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx
new file mode 100644
index 0000000..723c3ab
--- /dev/null
+++ b/src/components/admin/indexers/IndexerConfigModal.tsx
@@ -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
(
+ 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 (
+
+
+ {/* Indexer Info (readonly) */}
+
+
+ Indexer
+
+
+
+ {indexer.name}
+
+
+ {indexer.protocol}
+
+
+
+
+ {/* Priority */}
+
+
+ Priority (1-25)
+
+
handlePriorityChange(e.target.value)}
+ className={errors.priority ? 'border-red-500' : ''}
+ />
+
+ Higher values = preferred in ranking algorithm
+
+ {errors.priority && (
+
+ {errors.priority}
+
+ )}
+
+
+ {/* Seeding Time */}
+
+
+ Seeding Time (minutes)
+
+
handleSeedingTimeChange(e.target.value)}
+ placeholder="0"
+ className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
+ />
+
+ 0 = unlimited seeding (files remain seeded indefinitely)
+
+ {errors.seedingTimeMinutes && (
+
+ {errors.seedingTimeMinutes}
+
+ )}
+
+
+ {/* RSS Monitoring */}
+
+
+ RSS Monitoring
+
+
+ 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"
+ />
+
+ Auto-check RSS feeds every 15 minutes
+
+
+ {!indexer.supportsRss && (
+
+ This indexer does not support RSS monitoring
+
+ )}
+
+
+ {/* Categories */}
+
+
+ Categories
+
+
+
+
+
+ Select categories to search on this indexer. Parent selection locks all children as selected.
+
+ {errors.categories && (
+
+ {errors.categories}
+
+ )}
+
+
+ {/* Action Buttons */}
+
+
+ Cancel
+
+
+ {mode === 'add' ? 'Add Indexer' : 'Save Changes'}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx
new file mode 100644
index 0000000..749c9b8
--- /dev/null
+++ b/src/components/admin/indexers/IndexerManagement.tsx
@@ -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([]);
+ const [configuredIndexers, setConfiguredIndexers] = useState(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(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 (
+
+ {/* Section 1: Available Indexers */}
+
+
+
+ Available Indexers
+
+
+ {configuredIndexers.length > 0 || fetchedIndexers.length > 0
+ ? 'Refresh Indexers'
+ : 'Fetch Indexers'}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {fetchedIndexers.length > 0 && (
+
+ {fetchedIndexers.map((indexer) => (
+
openAddModal(indexer)}
+ />
+ ))}
+
+ )}
+
+ {!loading && fetchedIndexers.length === 0 && !error && (
+
+ {prowlarrUrl && prowlarrApiKey
+ ? 'Click "Fetch Indexers" to load available indexers from Prowlarr.'
+ : 'Enter Prowlarr URL and API key above, then fetch indexers.'}
+
+ )}
+
+
+ {/* Section 2: Configured Indexers */}
+
+
+ Configured Indexers ({configuredIndexers.length})
+
+
+ {configuredIndexers.length === 0 ? (
+
+
No indexers configured yet
+
+ Fetch indexers from Prowlarr and click "Add" to configure them.
+
+
+ ) : (
+
+ {configuredIndexers.map((config) => (
+ openEditModal(config)}
+ onDelete={() => handleDelete(config.id)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Config Modal */}
+ {modalState.isOpen && modalState.indexer && (
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+
setDeleteModalState({ isOpen: false })}
+ onConfirm={confirmDelete}
+ indexerName={deleteModalState.indexerName || ''}
+ />
+
+ );
+}
diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx
index fe40f11..96c2c09 100644
--- a/src/components/requests/InteractiveTorrentSearchModal.tsx
+++ b/src/components/requests/InteractiveTorrentSearchModal.tsx
@@ -77,8 +77,9 @@ export function InteractiveTorrentSearchModal({
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else {
- // New flow: search by custom title + original author
- data = await searchByAudiobook(searchTitle, audiobook.author);
+ // New flow: search by custom title + original author + optional ASIN for size scoring
+ const asin = fullAudiobook?.asin;
+ data = await searchByAudiobook(searchTitle, audiobook.author, asin);
}
setResults(data || []);
} catch (err) {
diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts
index 8e1b15d..9644489 100644
--- a/src/lib/hooks/useRequests.ts
+++ b/src/lib/hooks/useRequests.ts
@@ -308,7 +308,7 @@ export function useSearchTorrents() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
- const searchTorrents = async (title: string, author: string) => {
+ const searchTorrents = async (title: string, author: string, asin?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
@@ -322,7 +322,7 @@ export function useSearchTorrents() {
headers: {
'Content-Type': 'application/json',
},
- body: JSON.stringify({ title, author }),
+ body: JSON.stringify({ title, author, asin }),
});
const data = await response.json();
diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts
index c9e6b9a..5031245 100644
--- a/src/lib/integrations/audible.service.ts
+++ b/src/lib/integrations/audible.service.ts
@@ -805,6 +805,36 @@ export class AudibleService {
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 {
+ 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
*/
diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts
index 26be9df..b1cb8cf 100644
--- a/src/lib/integrations/prowlarr.service.ts
+++ b/src/lib/integrations/prowlarr.service.ts
@@ -12,12 +12,18 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Prowlarr');
export interface SearchFilters {
- category?: number;
+ category?: number; // Deprecated: use categories instead
+ categories?: number[]; // Array of category IDs to search
minSeeders?: number;
maxResults?: number;
indexerIds?: number[];
}
+export interface IndexerCategory {
+ id: number;
+ name: string;
+}
+
export interface Indexer {
id: number;
name: string;
@@ -26,6 +32,7 @@ export interface Indexer {
priority: number;
capabilities?: {
supportsRss?: boolean;
+ categories?: IndexerCategory[];
};
fields?: Array<{
name: string;
@@ -119,12 +126,23 @@ export class ProwlarrService {
const configService = getConfigService();
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 = {
query,
type: 'search',
limit: 100, // Maximum results to return from Prowlarr
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
diff --git a/src/lib/processors/monitor-rss-feeds.processor.ts b/src/lib/processors/monitor-rss-feeds.processor.ts
index fe96883..2ab5c76 100644
--- a/src/lib/processors/monitor-rss-feeds.processor.ts
+++ b/src/lib/processors/monitor-rss-feeds.processor.ts
@@ -100,6 +100,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
+ asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered search job for request ${request.id}`);
diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts
index 6dbde25..5a4d3cd 100644
--- a/src/lib/processors/plex-recently-added.processor.ts
+++ b/src/lib/processors/plex-recently-added.processor.ts
@@ -133,22 +133,22 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}
}
- // Check for downloaded requests to match
- const downloadedRequests = await prisma.request.findMany({
+ // Check for all non-terminal requests to match
+ const matchableRequests = await prisma.request.findMany({
where: {
- status: 'downloaded',
+ status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
include: { audiobook: true },
- take: 50,
+ take: 100,
});
- if (downloadedRequests.length > 0) {
- logger.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
+ if (matchableRequests.length > 0) {
+ logger.info(`Checking ${matchableRequests.length} matchable requests for matches (all non-terminal statuses)`);
const { findPlexMatch } = await import('../utils/audiobook-matcher');
- for (const request of downloadedRequests) {
+ for (const request of matchableRequests) {
try {
const audiobook = request.audiobook;
const match = await findPlexMatch({
@@ -159,7 +159,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
});
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
const updateData: any = { updatedAt: new Date() };
@@ -177,7 +181,15 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
await prisma.request.update({
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++;
@@ -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 {
success: true,
diff --git a/src/lib/processors/retry-missing-torrents.processor.ts b/src/lib/processors/retry-missing-torrents.processor.ts
index 823fab9..ddd1fd2 100644
--- a/src/lib/processors/retry-missing-torrents.processor.ts
+++ b/src/lib/processors/retry-missing-torrents.processor.ts
@@ -53,6 +53,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
+ asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts
index 87a328f..11a698a 100644
--- a/src/lib/processors/scan-plex.processor.ts
+++ b/src/lib/processors/scan-plex.processor.ts
@@ -316,23 +316,23 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
logger.info(`No orphaned audiobooks found`);
}
- // 6. Match downloaded requests against library
- logger.info(`Checking for downloaded requests to match...`);
- const downloadedRequests = await prisma.request.findMany({
+ // 6. Match all non-terminal requests against library
+ logger.info(`Checking for matchable requests...`);
+ const matchableRequests = await prisma.request.findMany({
where: {
- status: 'downloaded',
+ status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
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;
const { findPlexMatch } = await import('../utils/audiobook-matcher');
- for (const request of downloadedRequests) {
+ for (const request of matchableRequests) {
try {
const audiobook = request.audiobook;
@@ -346,7 +346,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
});
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)
const updateData: any = { updatedAt: new Date() };
@@ -362,12 +366,16 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
data: updateData,
});
- // Update request to available
+ // Update request to available and clear any error state
await prisma.request.update({
where: { id: request.id },
data: {
status: 'available',
completedAt: new Date(),
+ errorMessage: null, // Clear any error state
+ searchAttempts: 0, // Reset retry counters
+ downloadAttempts: 0,
+ importAttempts: 0,
updatedAt: new Date(),
},
});
@@ -389,7 +397,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
}
}
- logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
+ logger.info(`Matched ${matchedCount}/${matchableRequests.length} requests`, {
totalScanned: libraryItems.length,
newCount,
updatedCount,
diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts
index fed705c..8f80542 100644
--- a/src/lib/processors/search-indexers.processor.ts
+++ b/src/lib/processors/search-indexers.processor.ts
@@ -7,6 +7,7 @@ import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue
import { prisma } from '../db';
import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
+import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger';
/**
@@ -41,9 +42,8 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}
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.');
}
@@ -56,7 +56,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const flagConfigStr = await configService.get('indexer_flag_config');
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
const prowlarr = await getProwlarrService();
@@ -66,15 +75,31 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching for: "${searchQuery}"`);
- // Search indexers - ONLY enabled ones
- const searchResults = await prowlarr.search(searchQuery, {
- 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
- });
+ // Search Prowlarr for each group and combine results
+ const allResults = [];
- 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) {
// 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
const ranker = getRankingAlgorithm();
// Rank results with indexer priorities and flag configs
+ // Note: rankTorrents now filters out results < 20 MB internally
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
+ durationMinutes,
}, indexerPriorities, flagConfigs);
+ // Log filter results
+ const postFilterCount = rankedResults.length;
+ if (postFilterCount < preFilterCount) {
+ logger.info(`Filtered out ${preFilterCount - postFilterCount} results < ${sizeMBThreshold} MB`);
+ }
+
// Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
@@ -155,12 +210,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`--------------------------------------------------------`);
for (let i = 0; i < top3.length; 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(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
logger.info(``);
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
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(``);
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts
index f10cefa..6c6012d 100644
--- a/src/lib/services/job-queue.service.ts
+++ b/src/lib/services/job-queue.service.ts
@@ -37,6 +37,7 @@ export interface SearchIndexersPayload extends JobPayload {
id: string;
title: string;
author: string;
+ asin?: string; // Optional ASIN for runtime-based size scoring
};
}
@@ -441,7 +442,7 @@ export class JobQueueService {
/**
* Add search indexers job
*/
- async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string }): Promise {
+ async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise {
return await this.addJob(
'search_indexers',
{
diff --git a/src/lib/utils/indexer-grouping.ts b/src/lib/utils/indexer-grouping.ts
new file mode 100644
index 0000000..f6c72f3
--- /dev/null
+++ b/src/lib/utils/indexer-grouping.ts
@@ -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();
+
+ 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}]`;
+}
diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts
index b7a4505..44b0294 100644
--- a/src/lib/utils/ranking-algorithm.ts
+++ b/src/lib/utils/ranking-algorithm.ts
@@ -45,6 +45,7 @@ export interface BonusModifier {
export interface ScoreBreakdown {
formatScore: number;
+ sizeScore: number;
seederScore: number;
matchScore: number;
totalScore: number;
@@ -64,7 +65,7 @@ export class RankingAlgorithm {
/**
* Rank all torrents and return sorted by finalScore (best first)
* @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 flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
*/
@@ -74,13 +75,20 @@ export class RankingAlgorithm {
indexerPriorities?: Map,
flagConfigs?: IndexerFlagConfig[]
): 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)
const formatScore = this.scoreFormat(torrent);
+ const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
- const baseScore = formatScore + seederScore + matchScore;
+ const baseScore = formatScore + sizeScore + seederScore + matchScore;
// Calculate bonus modifiers
const bonusModifiers: BonusModifier[] = [];
@@ -136,16 +144,18 @@ export class RankingAlgorithm {
rank: 0, // Will be assigned after sorting
breakdown: {
formatScore,
+ sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: this.generateNotes(torrent, {
formatScore,
+ sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: [],
- }),
+ }, audiobook.durationMinutes),
},
};
});
@@ -176,48 +186,89 @@ export class RankingAlgorithm {
audiobook: AudiobookRequest
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
+ const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
- const totalScore = formatScore + seederScore + matchScore;
+ const totalScore = formatScore + sizeScore + seederScore + matchScore;
return {
formatScore,
+ sizeScore,
seederScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
+ sizeScore,
seederScore,
matchScore,
totalScore,
notes: [],
- }),
+ }, audiobook.durationMinutes),
};
}
/**
- * Score format quality (25 points max)
- * M4B with chapters: 25 pts
- * M4B without chapters: 22 pts
- * M4A: 16 pts
- * MP3: 10 pts
- * Other: 3 pts
+ * Score format quality (10 points max)
+ * Reduced from 25 to make room for data-driven size scoring
+ * M4B with chapters: 10 pts
+ * M4B without chapters: 9 pts
+ * M4A: 6 pts
+ * MP3: 4 pts
+ * Other: 1 pt
*/
private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent);
switch (format) {
case 'M4B':
- return torrent.hasChapters !== false ? 25 : 22;
+ return torrent.hasChapters !== false ? 10 : 9;
case 'M4A':
- return 16;
+ return 6;
case 'MP3':
- return 10;
+ return 4;
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)
* Logarithmic scaling:
@@ -429,7 +480,8 @@ export class RankingAlgorithm {
*/
private generateNotes(
torrent: TorrentResult,
- breakdown: ScoreBreakdown
+ breakdown: ScoreBreakdown,
+ runtimeMinutes?: number
): string[] {
const notes: string[] = [];
@@ -448,6 +500,24 @@ export class RankingAlgorithm {
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)
if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
if (torrent.seeders === 0) {
diff --git a/src/lib/utils/torrent-categories.ts b/src/lib/utils/torrent-categories.ts
new file mode 100644
index 0000000..55006d9
--- /dev/null
+++ b/src/lib/utils/torrent-categories.ts
@@ -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;
+}