From bc371860d06c921e2f474ada226b7a3c6427fca5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 18 Aug 2026 17:26:27 -0400 Subject: [PATCH] Fix search result limiting after ranking (#277) --- documentation/phase3/prowlarr.md | 10 ++-- .../api/audiobooks/search-torrents/route.ts | 20 ++++++-- .../requests/[id]/interactive-search/route.ts | 20 ++++++-- .../processors/search-indexers.processor.ts | 12 +++-- tests/api/audiobooks-search.routes.test.ts | 43 +++++++++++++++++ tests/api/requests-actions.routes.test.ts | 45 ++++++++++++++++++ .../search-indexers.processor.test.ts | 46 +++++++++++++++++++ 7 files changed, 181 insertions(+), 15 deletions(-) diff --git a/documentation/phase3/prowlarr.md b/documentation/phase3/prowlarr.md index d6af722..935e603 100644 --- a/documentation/phase3/prowlarr.md +++ b/documentation/phase3/prowlarr.md @@ -27,7 +27,8 @@ Indexer aggregator for searching multiple torrent/usenet indexers simultaneously - Minimum score threshold: 50/100 - Filters applied after ranking to remove poor matches - Ensures at least basic title/author match quality -- maxResults: 100 (increased from 50 for broader search) +- Audiobook searches rank the full deduplicated result set, then retain the best 100 +- Interactive responses expose `truncated` and the pre-ranking `rawCount` **Example:** "Season of Storms" → finds all "Season of Storms" torrents → ranks by author match → filters score < 50 @@ -66,16 +67,17 @@ interface TorrentResult { **Manual Search** (`POST /api/requests/{id}/manual-search`) - Triggers automatic search job for requests with status: pending, failed, awaiting_search -- Searches only enabled indexers (title only, maxResults: 100) +- Searches only enabled indexers (title only) - Ranks all results, filters scores < 50 +- Retains the best 100 qualifying results after ranking - Selects best torrent from filtered results - Updates request status to 'pending' **Interactive Search** (`POST /api/requests/{id}/interactive-search`) - Returns ranked torrent results for user selection -- Searches only enabled indexers (title only or custom, maxResults: 100) +- Searches only enabled indexers (title only or custom) - Accepts optional custom search title in request body -- Ranks all results, filters scores < 50 +- Ranks all results, then returns the best 100 with truncation metadata - Shows table with: rank, title, size, quality score, seeders, indexer, publish date - Editable title field allows search refinement - Available for same statuses as manual search diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts index 59b8bfe..016b83b 100644 --- a/src/app/api/audiobooks/search-torrents/route.ts +++ b/src/app/api/audiobooks/search-torrents/route.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.AudiobookSearch'); +const MAX_RANKED_RESULTS = 100; const SearchSchema = z.object({ title: z.string(), @@ -98,7 +99,6 @@ export async function POST(request: NextRequest) { const groupResults = await prowlarr.searchWithVariations(title, author, { categories: group.categories, indexerIds: group.indexerIds, - maxResults: 100, // Limit per group }); logger.debug(`Group ${i + 1} returned ${groupResults.length} results`); @@ -116,6 +116,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, results: [], + truncated: false, + rawCount: 0, message: 'No torrents/nzbs found', }); } @@ -167,6 +169,14 @@ export async function POST(request: NextRequest) { // User can see scores and make their own decision logger.debug(`Ranked ${rankedResults.length} results (no threshold filter - user decides)`); + const truncated = rankedResults.length > MAX_RANKED_RESULTS; + const limitedRankedResults = rankedResults.slice(0, MAX_RANKED_RESULTS); + if (truncated) { + logger.info(`Audiobook search truncated after ranking: returning top ${MAX_RANKED_RESULTS} of ${rankedResults.length} ranked results`, { + rawCount: results.length, + }); + } + // Log top 3 results with detailed score breakdown for debugging const top3 = rankedResults.slice(0, 3); if (top3.length > 0) { @@ -198,7 +208,7 @@ export async function POST(request: NextRequest) { } // Add rank position to each result - const resultsWithRank = rankedResults.map((result, index) => ({ + const resultsWithRank = limitedRankedResults.map((result, index) => ({ ...result, rank: index + 1, })); @@ -206,8 +216,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, results: resultsWithRank, - message: rankedResults.length > 0 - ? `Found ${rankedResults.length} results` + truncated, + rawCount: results.length, + message: limitedRankedResults.length > 0 + ? `Found ${limitedRankedResults.length} results` : 'No results found', }); } catch (error) { diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 5bfd7ae..8917dd3 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -15,6 +15,7 @@ import { RMABLogger } from '@/lib/utils/logger'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; const logger = RMABLogger.create('API.InteractiveSearch'); +const MAX_RANKED_RESULTS = 100; /** * POST /api/requests/[id]/interactive-search @@ -151,7 +152,6 @@ export async function POST( const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, { categories: group.categories, indexerIds: group.indexerIds, - maxResults: 100, }); logger.debug(`Group ${i + 1} returned ${groupResults.length} results`); @@ -169,6 +169,8 @@ export async function POST( return NextResponse.json({ success: true, results: [], + truncated: false, + rawCount: 0, message: 'No torrents/nzbs found', }); } @@ -214,6 +216,14 @@ export async function POST( // User can see scores and make their own decision logger.debug(`Ranked ${rankedResults.length} results (no threshold filter - user decides)`); + const truncated = rankedResults.length > MAX_RANKED_RESULTS; + const limitedRankedResults = rankedResults.slice(0, MAX_RANKED_RESULTS); + if (truncated) { + logger.info(`Interactive search truncated after ranking: returning top ${MAX_RANKED_RESULTS} of ${rankedResults.length} ranked results`, { + rawCount: results.length, + }); + } + // Log top 3 results with detailed score breakdown for debugging const top3 = rankedResults.slice(0, 3); if (top3.length > 0) { @@ -245,7 +255,7 @@ export async function POST( } // Add rank position to each result - const resultsWithRank = rankedResults.map((result, index) => ({ + const resultsWithRank = limitedRankedResults.map((result, index) => ({ ...result, rank: index + 1, })); @@ -253,8 +263,10 @@ export async function POST( return NextResponse.json({ success: true, results: resultsWithRank, - message: rankedResults.length > 0 - ? `Found ${rankedResults.length} results` + truncated, + rawCount: results.length, + message: limitedRankedResults.length > 0 + ? `Found ${limitedRankedResults.length} results` : 'No results found', }); } catch (error) { diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts index ca80ec6..4417308 100644 --- a/src/lib/processors/search-indexers.processor.ts +++ b/src/lib/processors/search-indexers.processor.ts @@ -13,6 +13,8 @@ import { getLanguageForRegion } from '../constants/language-config'; import { filterBlockedResults } from '../utils/filter-blocked-results'; import type { AudibleRegion } from '../types/audible'; +const MAX_RANKED_RESULTS = 100; + /** * Process search indexers job * Searches configured indexers for audiobook torrents @@ -103,7 +105,6 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro 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`); @@ -201,15 +202,20 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro // Dual threshold filtering: // 1. Base score must be >= 50 (quality minimum) // 2. Final score must be >= 50 (not disqualified by negative bonuses) - const filteredResults = rankedResults.filter(result => + const qualifyingResults = rankedResults.filter(result => result.score >= 50 && result.finalScore >= 50 ); + const resultsTruncated = qualifyingResults.length > MAX_RANKED_RESULTS; + const filteredResults = qualifyingResults.slice(0, MAX_RANKED_RESULTS); const disqualifiedByNegativeBonus = rankedResults.filter(result => result.score >= 50 && result.finalScore < 50 ).length; - logger.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`); + logger.info(`Ranked ${rankedResults.length} results, ${qualifyingResults.length} above threshold (50/100 base + final)`); + if (resultsTruncated) { + logger.info(`Limited automatic search to the top ${MAX_RANKED_RESULTS} of ${qualifyingResults.length} qualifying results after ranking`); + } if (disqualifiedByNegativeBonus > 0) { logger.info(`${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`); } diff --git a/tests/api/audiobooks-search.routes.test.ts b/tests/api/audiobooks-search.routes.test.ts index d517404..81f91b9 100644 --- a/tests/api/audiobooks-search.routes.test.ts +++ b/tests/api/audiobooks-search.routes.test.ts @@ -20,6 +20,10 @@ const rankTorrentsMock = vi.hoisted(() => vi.fn()); const groupIndexersMock = vi.hoisted(() => vi.fn()); const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group')); +vi.mock('@/lib/db', () => ({ + prisma: {}, +})); + vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, })); @@ -94,6 +98,45 @@ describe('Audiobooks search torrents route', () => { expect(payload.results[0].rank).toBe(1); expect(rankTorrentsMock).toHaveBeenCalled(); }); + + it('ranks all candidates before returning the top 100 with truncation metadata', async () => { + authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' }); + configServiceMock.get + .mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }])) + .mockResolvedValueOnce(null); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] }); + + const candidates = Array.from({ length: 101 }, (_, index) => ({ + title: index === 100 ? 'Best result' : `Result ${index}`, + size: 100, + indexer: 'Indexer', + indexerId: 1, + })); + const rankedCandidates = [candidates[100], ...candidates.slice(0, 100)].map((result, index) => ({ + ...result, + score: 100 - index, + breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, + bonusPoints: 0, + bonusModifiers: [], + finalScore: 100 - index, + })); + prowlarrMock.searchWithVariations.mockResolvedValue(candidates); + rankTorrentsMock.mockReturnValue(rankedCandidates); + + const { POST } = await import('@/app/api/audiobooks/search-torrents/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(rankTorrentsMock.mock.calls[0][0]).toHaveLength(101); + expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Title', 'Author', { + categories: [3030], + indexerIds: [1], + }); + expect(payload.results).toHaveLength(100); + expect(payload.results[0]).toEqual(expect.objectContaining({ title: 'Best result', rank: 1 })); + expect(payload.truncated).toBe(true); + expect(payload.rawCount).toBe(101); + }); }); diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index af8a830..f065acb 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -153,6 +153,51 @@ describe('Request action routes', () => { ); }); + it('ranks the complete interactive result set before returning the top 100', async () => { + authRequest.json.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-complete-ranking', + userId: 'user-1', + audiobook: { title: 'Title', author: 'Author', audibleAsin: null }, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + role: 'user', + interactiveSearchAccess: null, + }); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }])); + configServiceMock.get.mockResolvedValueOnce(null); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] }); + + const candidates = Array.from({ length: 101 }, (_, index) => ({ + title: index === 100 ? 'Best result' : `Result ${index}`, + size: 100, + })); + const rankedCandidates = [candidates[100], ...candidates.slice(0, 100)].map((result, index) => ({ + ...result, + score: 100 - index, + breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, + bonusPoints: 0, + bonusModifiers: [], + finalScore: 100 - index, + })); + prowlarrMock.searchWithVariations.mockResolvedValueOnce(candidates); + rankTorrentsMock.mockReturnValueOnce(rankedCandidates); + + const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-complete-ranking' }) }); + const payload = await response.json(); + + expect(rankTorrentsMock.mock.calls[0][0]).toHaveLength(101); + expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Title', 'Author', { + categories: [3030], + indexerIds: [1], + }); + expect(payload.results).toHaveLength(100); + expect(payload.results[0]).toEqual(expect.objectContaining({ title: 'Best result', rank: 1 })); + expect(payload.truncated).toBe(true); + expect(payload.rawCount).toBe(101); + }); + it('performs interactive search gracefully when runtime fetch fails', async () => { authRequest.json.mockResolvedValue({}); prismaMock.request.findUnique.mockResolvedValueOnce({ diff --git a/tests/processors/search-indexers.processor.test.ts b/tests/processors/search-indexers.processor.test.ts index 3b89a97..2eeaf21 100644 --- a/tests/processors/search-indexers.processor.test.ts +++ b/tests/processors/search-indexers.processor.test.ts @@ -117,6 +117,52 @@ describe('processSearchIndexers', () => { expect.objectContaining({ title: 'Book - Author' }), [expect.objectContaining({ title: 'Book - Author - Alternate' })] ); + expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Book', 'Author', { + categories: [3030], + indexerIds: [1], + minSeeders: 1, + }); + }); + + it('ranks all candidates before limiting automatic search to 100 results', async () => { + configMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]); + } + if (key === 'indexer_flag_config') return JSON.stringify([]); + return null; + }); + + const candidates = Array.from({ length: 101 }, (_, index) => ({ + indexer: 'Indexer', + indexerId: 1, + title: 'Book - Author', + size: 50 * 1024 * 1024, + seeders: index === 100 ? 500 : 1, + publishDate: new Date(), + downloadUrl: `magnet:?xt=urn:btih:${index}`, + guid: `guid-${index}`, + format: 'M4B', + })); + prowlarrMock.searchWithVariations.mockResolvedValue(candidates); + prismaMock.request.update.mockResolvedValue({}); + + const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); + const result = await processSearchIndexers({ + requestId: 'req-rank-before-limit', + audiobook: { id: 'a-rank-before-limit', title: 'Book', author: 'Author' }, + jobId: 'job-rank-before-limit', + }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith( + 'req-rank-before-limit', + expect.anything(), + expect.objectContaining({ guid: 'guid-100' }), + expect.any(Array) + ); + const fallbackResults = jobQueueMock.addDownloadJob.mock.calls[0][3]; + expect(fallbackResults).toHaveLength(99); }); it('fails when no indexers are configured', async () => {