From ca02b8b6e7738f8a73ebcbaa7f63d6226e023c4a Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 16:32:09 -0500 Subject: [PATCH] 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. --- .../components/RequestActionsDropdown.tsx | 14 +++-- .../[id]/interactive-search-ebook/route.ts | 59 ++++++++++++------- .../api/requests/[id]/manual-search/route.ts | 12 +++- src/components/requests/RequestCard.tsx | 57 +----------------- 4 files changed, 56 insertions(+), 86 deletions(-) diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 9de721c..da60da1 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -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 */} diff --git a/src/app/api/requests/[id]/interactive-search-ebook/route.ts b/src/app/api/requests/[id]/interactive-search-ebook/route.ts index 379c279..6ff4285 100644 --- a/src/app/api/requests/[id]/interactive-search-ebook/route.ts +++ b/src/app/api/requests/[id]/interactive-search-ebook/route.ts @@ -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 diff --git a/src/app/api/requests/[id]/manual-search/route.ts b/src/app/api/requests/[id]/manual-search/route.ts index d155356..aa52def 100644 --- a/src/app/api/requests/[id]/manual-search/route.ts +++ b/src/app/api/requests/[id]/manual-search/route.ts @@ -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({ diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index fcae0e7..d54ad21 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -9,11 +9,9 @@ import React from 'react'; import Image from 'next/image'; import { StatusBadge } from './StatusBadge'; import { Button } from '@/components/ui/Button'; -import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests'; +import { useCancelRequest } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { usePreferences } from '@/contexts/PreferencesContext'; -import { useAuth } from '@/contexts/AuthContext'; -import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses'; @@ -43,11 +41,8 @@ interface RequestCardProps { export function RequestCard({ request, showActions = true }: RequestCardProps) { const { cancelRequest, isLoading } = useCancelRequest(); - const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch(); const { squareCovers } = usePreferences(); - const { user } = useAuth(); const [showError, setShowError] = React.useState(false); - const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false); const [showDetailsModal, setShowDetailsModal] = React.useState(false); const requestType = request.type || 'audiobook'; @@ -57,10 +52,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; - // Ebook requests don't support interactive search (Anna's Archive only) - // Interactive search also requires the interactiveSearch permission - const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false; - const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const handleCancel = async () => { if (window.confirm('Are you sure you want to cancel this request?')) { @@ -72,20 +63,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { } }; - const handleManualSearch = async () => { - try { - await triggerManualSearch(request.id); - // Request list will auto-refresh via SWR - } catch (error) { - console.error('Failed to trigger manual search:', error); - alert(error instanceof Error ? error.message : 'Failed to trigger manual search'); - } - }; - - const handleInteractiveSearch = () => { - setShowInteractiveSearch(true); - }; - const formatDate = (dateString: string) => { const date = new Date(dateString); const now = new Date(); @@ -255,27 +232,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { {/* Action Buttons */} {showActions && (
- {canSearch && ( - <> - - - - )} {canCancel && (
- {/* Interactive Search Modal */} - setShowInteractiveSearch(false)} - requestId={request.id} - audiobook={{ - title: request.audiobook.title, - author: request.audiobook.author, - }} - /> - {/* Audiobook Details Modal */} {request.audiobook.audibleAsin && (