From 307b63fab4e75f710b8139ab6055f7e14646e0af Mon Sep 17 00:00:00 2001
From: kikootwo
Date: Tue, 13 Jan 2026 21:32:54 -0500
Subject: [PATCH] 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.
---
documentation/backend/services/jobs.md | 20 +-
documentation/integrations/plex.md | 34 ++
src/app/admin/settings/page.tsx | 318 ++--------------
src/app/admin/settings/tabs/IndexersTab.tsx | 172 +++++++++
.../admin/settings/prowlarr/indexers/route.ts | 7 +-
.../api/audiobooks/search-torrents/route.ts | 87 ++++-
src/app/api/bookdate/swipe/route.ts | 1 +
.../api/requests/[id]/manual-search/route.ts | 1 +
src/app/api/requests/[id]/route.ts | 1 +
src/app/api/requests/route.ts | 1 +
src/app/setup/steps/ProwlarrStep.tsx | 359 ++----------------
.../admin/indexers/AvailableIndexerRow.tsx | 76 ++++
.../admin/indexers/CategoryTreeView.tsx | 165 ++++++++
.../admin/indexers/DeleteConfirmModal.tsx | 78 ++++
src/components/admin/indexers/IndexerCard.tsx | 81 ++++
.../admin/indexers/IndexerConfigModal.tsx | 280 ++++++++++++++
.../admin/indexers/IndexerManagement.tsx | 285 ++++++++++++++
.../InteractiveTorrentSearchModal.tsx | 5 +-
src/lib/hooks/useRequests.ts | 4 +-
src/lib/integrations/audible.service.ts | 30 ++
src/lib/integrations/prowlarr.service.ts | 22 +-
.../processors/monitor-rss-feeds.processor.ts | 1 +
.../plex-recently-added.processor.ts | 32 +-
.../retry-missing-torrents.processor.ts | 1 +
src/lib/processors/scan-plex.processor.ts | 28 +-
.../processors/search-indexers.processor.ts | 83 +++-
src/lib/services/job-queue.service.ts | 3 +-
src/lib/utils/indexer-grouping.ts | 101 +++++
src/lib/utils/ranking-algorithm.ts | 104 ++++-
src/lib/utils/torrent-categories.ts | 78 ++++
30 files changed, 1787 insertions(+), 671 deletions(-)
create mode 100644 src/app/admin/settings/tabs/IndexersTab.tsx
create mode 100644 src/components/admin/indexers/AvailableIndexerRow.tsx
create mode 100644 src/components/admin/indexers/CategoryTreeView.tsx
create mode 100644 src/components/admin/indexers/DeleteConfirmModal.tsx
create mode 100644 src/components/admin/indexers/IndexerCard.tsx
create mode 100644 src/components/admin/indexers/IndexerConfigModal.tsx
create mode 100644 src/components/admin/indexers/IndexerManagement.tsx
create mode 100644 src/lib/utils/indexer-grouping.ts
create mode 100644 src/lib/utils/torrent-categories.ts
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;
+}