mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Refactor indexer management and improve search logic
Refactors admin settings to use a new IndexersTab and card-based indexer management UI, supporting category selection and improved configuration. Updates backend and API routes to handle indexer categories, propagate ASIN for better search scoring, and group indexers by categories to optimize Prowlarr searches. Enhances documentation to clarify non-terminal request matching and auto-completion behavior. Adds new reusable components for indexer management and category selection.
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -316,23 +316,23 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
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<any> {
|
||||
});
|
||||
|
||||
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<any> {
|
||||
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<any> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
|
||||
logger.info(`Matched ${matchedCount}/${matchableRequests.length} requests`, {
|
||||
totalScanned: libraryItems.length,
|
||||
newCount,
|
||||
updatedCount,
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user