Enable ebook interactive search and job routing

Add support for interactive ebook searches and streamline search job routing. Key changes:

- RequestActionsDropdown: loosened status checks for search/adjust actions, route interactive search to an ebook-specific modal when the request is an ebook, and pass request.customSearchTerms to the ebook search modal.
- API: interactive-search-ebook route now supports two flows (direct ebook requests and audiobook sidecar ebook searches), updates validation logic, checks for existing child ebook requests only in sidecar mode, and improves logging. manual-search route now dispatches addSearchEbookJob for ebook requests and addSearchJob for audiobooks.
- RequestCard: removed manual/interactive search UI, related hooks and modal usage (interactive search is handled via the admin dropdown/modal now).

These changes enable direct ebook interactive search flows, prevent invalid searches based on request type/status, and ensure the correct background job is enqueued per request type.
This commit is contained in:
kikootwo
2026-03-04 16:32:09 -05:00
parent 441724c378
commit ca02b8b6e7
4 changed files with 56 additions and 86 deletions
@@ -62,10 +62,9 @@ export function RequestActionsDropdown({
// View Details: available when ASIN exists (audiobook requests only)
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
const handleInteractiveSearch = () => {
setIsOpen(false);
setShowInteractiveSearch(true);
if (isEbook) {
setShowInteractiveSearchEbook(true);
} else {
setShowInteractiveSearch(true);
}
};
const handleAdjustSearchTerms = () => {
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
author: request.author,
}}
searchMode="ebook"
customSearchTerms={request.customSearchTerms}
/>
{/* Adjust Search Terms Modal */}
@@ -71,41 +71,56 @@ export async function POST(
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request (can be audiobook parent or direct ebook request)
const requestRecord = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!requestRecord) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
// Support two flows:
// Flow A (sidecar): Audiobook request in downloaded/available state
// Flow B (direct): Ebook request in pending/failed/awaiting_search state
const isDirectEbookSearch = requestRecord.type === 'ebook';
const isAudiobookSidecar = requestRecord.type === 'audiobook';
if (!isDirectEbookSearch && !isAudiobookSidecar) {
return NextResponse.json({ error: 'Invalid request type' }, { status: 400 });
}
if (!['downloaded', 'available'].includes(parentRequest.status)) {
if (isAudiobookSidecar && !['downloaded', 'available'].includes(requestRecord.status)) {
return NextResponse.json(
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
{ error: `Cannot search ebooks for audiobook request in ${requestRecord.status} status` },
{ status: 400 }
);
}
// Check for existing non-retryable ebook request
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
return NextResponse.json(
{ error: `Cannot search for ebook request in ${requestRecord.status} status` },
{ status: 400 }
);
}
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
// Check for existing child ebook requests (sidecar mode only)
if (isAudiobookSidecar) {
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
}
}
// Get ebook configuration
@@ -135,10 +150,10 @@ export async function POST(
);
}
const audiobook = parentRequest.audiobook;
const audiobook = requestRecord.audiobook;
const searchTitle = customTitle || audiobook.title;
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author} (${isDirectEbookSearch ? 'direct' : 'sidecar'})`);
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
// Search both sources in parallel
@@ -64,14 +64,20 @@ export async function POST(
);
}
// Trigger search job
// Trigger appropriate search job based on request type
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
asin: requestRecord.audiobook.audibleAsin || undefined,
});
};
if (requestRecord.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
// Update request status
const updated = await prisma.request.update({