mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -62,10 +62,9 @@ export function RequestActionsDropdown({
|
|||||||
// View Details: available when ASIN exists (audiobook requests only)
|
// View Details: available when ASIN exists (audiobook requests only)
|
||||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||||
|
|
||||||
// Determine available actions based on status and type
|
// Determine available actions based on status
|
||||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
|
||||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
|
|||||||
|
|
||||||
const handleInteractiveSearch = () => {
|
const handleInteractiveSearch = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
if (isEbook) {
|
||||||
|
setShowInteractiveSearchEbook(true);
|
||||||
|
} else {
|
||||||
setShowInteractiveSearch(true);
|
setShowInteractiveSearch(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdjustSearchTerms = () => {
|
const handleAdjustSearchTerms = () => {
|
||||||
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
|
|||||||
author: request.author,
|
author: request.author,
|
||||||
}}
|
}}
|
||||||
searchMode="ebook"
|
searchMode="ebook"
|
||||||
|
customSearchTerms={request.customSearchTerms}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Adjust Search Terms Modal */}
|
{/* Adjust Search Terms Modal */}
|
||||||
|
|||||||
@@ -71,28 +71,42 @@ export async function POST(
|
|||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const customTitle = body.customTitle as string | undefined;
|
const customTitle = body.customTitle as string | undefined;
|
||||||
|
|
||||||
// Get the parent audiobook request
|
// Get the request (can be audiobook parent or direct ebook request)
|
||||||
const parentRequest = await prisma.request.findUnique({
|
const requestRecord = await prisma.request.findUnique({
|
||||||
where: { id: parentRequestId },
|
where: { id: parentRequestId },
|
||||||
include: { audiobook: true },
|
include: { audiobook: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parentRequest) {
|
if (!requestRecord) {
|
||||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentRequest.type !== 'audiobook') {
|
// Support two flows:
|
||||||
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
|
// 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(
|
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 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing non-retryable ebook request
|
if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Cannot search for ebook request in ${requestRecord.status} status` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing child ebook requests (sidecar mode only)
|
||||||
|
if (isAudiobookSidecar) {
|
||||||
const existingEbookRequest = await prisma.request.findFirst({
|
const existingEbookRequest = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
parentRequestId,
|
parentRequestId,
|
||||||
@@ -107,6 +121,7 @@ export async function POST(
|
|||||||
existingRequestId: existingEbookRequest.id,
|
existingRequestId: existingEbookRequest.id,
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get ebook configuration
|
// Get ebook configuration
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
@@ -135,10 +150,10 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audiobook = parentRequest.audiobook;
|
const audiobook = requestRecord.audiobook;
|
||||||
const searchTitle = customTitle || audiobook.title;
|
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}`);
|
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
|
||||||
|
|
||||||
// Search both sources in parallel
|
// 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();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSearchJob(id, {
|
const audiobookData = {
|
||||||
id: requestRecord.audiobook.id,
|
id: requestRecord.audiobook.id,
|
||||||
title: requestRecord.audiobook.title,
|
title: requestRecord.audiobook.title,
|
||||||
author: requestRecord.audiobook.author,
|
author: requestRecord.audiobook.author,
|
||||||
asin: requestRecord.audiobook.audibleAsin || undefined,
|
asin: requestRecord.audiobook.audibleAsin || undefined,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (requestRecord.type === 'ebook') {
|
||||||
|
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||||
|
} else {
|
||||||
|
await jobQueue.addSearchJob(id, audiobookData);
|
||||||
|
}
|
||||||
|
|
||||||
// Update request status
|
// Update request status
|
||||||
const updated = await prisma.request.update({
|
const updated = await prisma.request.update({
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import React from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { Button } from '@/components/ui/Button';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
@@ -43,11 +41,8 @@ interface RequestCardProps {
|
|||||||
|
|
||||||
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||||
const { cancelRequest, isLoading } = useCancelRequest();
|
const { cancelRequest, isLoading } = useCancelRequest();
|
||||||
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
|
|
||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
const { user } = useAuth();
|
|
||||||
const [showError, setShowError] = React.useState(false);
|
const [showError, setShowError] = React.useState(false);
|
||||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
|
||||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||||
|
|
||||||
const requestType = request.type || 'audiobook';
|
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 canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
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 () => {
|
const handleCancel = async () => {
|
||||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
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 formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -255,27 +232,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canSearch && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={handleManualSearch}
|
|
||||||
loading={isManualSearching}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
Manual Search
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleInteractiveSearch}
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
Interactive Search
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
@@ -293,17 +249,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Interactive Search Modal */}
|
|
||||||
<InteractiveTorrentSearchModal
|
|
||||||
isOpen={showInteractiveSearch}
|
|
||||||
onClose={() => setShowInteractiveSearch(false)}
|
|
||||||
requestId={request.id}
|
|
||||||
audiobook={{
|
|
||||||
title: request.audiobook.title,
|
|
||||||
author: request.audiobook.author,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Audiobook Details Modal */}
|
{/* Audiobook Details Modal */}
|
||||||
{request.audiobook.audibleAsin && (
|
{request.audiobook.audibleAsin && (
|
||||||
<AudiobookDetailsModal
|
<AudiobookDetailsModal
|
||||||
|
|||||||
Reference in New Issue
Block a user