Fix search result limiting after ranking

This commit is contained in:
kikootwo
2026-08-18 17:20:56 -04:00
parent 425129e8c9
commit 73dc389c09
7 changed files with 181 additions and 15 deletions
+6 -4
View File
@@ -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
@@ -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) {
@@ -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) {
@@ -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`);
}
@@ -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);
});
});
+45
View File
@@ -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({
@@ -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 () => {