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 && (