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. -

-
- -
- - { - 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" - /> -
- -
- - { - 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 -

-
- -
- - {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} - -
-
-
- - { - 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

-
-
- - { - 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

-
-
- -
- { - 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)); - }} - /> - ))} -
- )} - - - - {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. +

+
+ +
+ + { + 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" + /> +
+ +
+ + { + 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)); + }} + /> + ))} +
+ )} + + + + {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({

- - - {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" - /> - - {selectedIndexers[indexer.id] && ( -
-
- - - 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" - /> -
-
- - - 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 && ( -
- - 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 */} +
- +
); 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 ? ( +
+ + + + + Added + +
+ ) : ( + + )} +
+
+ ); +} 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. +

+
+
+ +
+ + +
+
+
+ ); +} 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.name} + + + {indexer.protocol} + +
+
+ + {/* Priority */} +
+ + handlePriorityChange(e.target.value)} + className={errors.priority ? 'border-red-500' : ''} + /> +

+ Higher values = preferred in ranking algorithm +

+ {errors.priority && ( +

+ {errors.priority} +

+ )} +
+ + {/* Seeding Time */} +
+ + 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 */} +
+ +
+ 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 */} +
+ +
+ +
+

+ Select categories to search on this indexer. Parent selection locks all children as selected. +

+ {errors.categories && ( +

+ {errors.categories} +

+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +} 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 +

+ +
+ + {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; +}