mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-09-01 05:08:38 +00:00
Fix search result limiting after ranking
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user