/** * Component: Interactive Torrent Search Modal * Documentation: documentation/phase3/prowlarr.md * * Supports two search modes: * - audiobook: Search for audiobook torrents/NZBs (default) * - ebook: Search for ebooks from Anna's Archive + indexers */ 'use client'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent, useInteractiveSearchEbook, useSelectEbook, useInteractiveSearchEbookByAsin, useSelectEbookByAsin, } from '@/lib/hooks/useRequests'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; interface InteractiveTorrentSearchModalProps { isOpen: boolean; onClose: () => void; requestId?: string; // Optional - only provided when called from existing request asin?: string; // Optional - ASIN for ebook mode when no request exists audiobook: { title: string; author: string; }; fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook } // Format relative time from publish date const formatAge = (date: Date | string): string => { const now = new Date(); const d = new Date(date); const diffMs = now.getTime() - d.getTime(); if (diffMs < 0) return 'Soon'; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return 'Today'; if (diffDays === 1) return '1d ago'; if (diffDays < 30) return `${diffDays}d ago`; const months = Math.floor(diffDays / 30.44); if (months < 12) return `${months}mo ago`; const years = Math.floor(diffDays / 365.25); return `${years}y ago`; }; // Format file size const formatSize = (bytes: number): string => { const gb = bytes / (1024 ** 3); const mb = bytes / (1024 ** 2); return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`; }; // Score badge color scheme const getScoreStyle = (score: number) => { if (score >= 90) return { bg: 'bg-emerald-500/15 dark:bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-400' }; if (score >= 70) return { bg: 'bg-blue-500/15 dark:bg-blue-400/15', text: 'text-blue-700 dark:text-blue-400' }; if (score >= 50) return { bg: 'bg-amber-500/15 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-400' }; return { bg: 'bg-gray-500/10 dark:bg-gray-400/10', text: 'text-gray-500 dark:text-gray-400' }; }; // Skeleton widths for loading state (deterministic to avoid hydration mismatch) const skeletonRows = [ { title: '72%', meta: '48%' }, { title: '85%', meta: '58%' }, { title: '64%', meta: '42%' }, { title: '78%', meta: '52%' }, { title: '68%', meta: '45%' }, ]; export function InteractiveTorrentSearchModal({ isOpen, onClose, requestId, asin, audiobook, fullAudiobook, onSuccess, searchMode = 'audiobook', }: InteractiveTorrentSearchModalProps) { // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent(); // Hooks for new audiobook flow const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); // Hooks for ebook flow (request ID-based - admin) const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook(); const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook(); // Hooks for ebook flow (ASIN-based - user) const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin(); const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin(); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); const [searchTitle, setSearchTitle] = useState(audiobook.title); const [mounted, setMounted] = useState(false); // Stable close handler via ref const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const handleClose = useCallback(() => { onCloseRef.current(); }, []); // Determine which mode we're in const isEbookMode = searchMode === 'ebook'; const hasRequestId = !!requestId; const hasAsin = !!asin; const useAsinMode = isEbookMode && hasAsin && !hasRequestId; // Loading/error state based on mode const isSearching = isEbookMode ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); const isDownloading = isEbookMode ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); const error = isEbookMode ? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError)) : (hasRequestId ? (searchByRequestError || selectTorrentError) : (searchByAudiobookError || requestWithTorrentError)); // Mount tracking for portal useEffect(() => { setMounted(true); }, []); // Reset search title when modal opens/closes or audiobook changes useEffect(() => { setSearchTitle(audiobook.title); setResults([]); }, [isOpen, audiobook.title]); // Perform search when modal opens useEffect(() => { if (isOpen && results.length === 0) { performSearch(); } }, [isOpen]); // ESC key and body scroll lock // ESC dismisses confirmation first, then closes modal useEffect(() => { if (!isOpen) return; const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (confirmTorrent) { setConfirmTorrent(null); } else { handleClose(); } } }; document.addEventListener('keydown', handleEsc); document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleEsc); document.body.style.overflow = ''; }; }, [isOpen, handleClose, confirmTorrent]); const performSearch = async () => { setResults([]); try { let data; if (isEbookMode) { const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; if (useAsinMode && asin) { data = await searchEbooksByAsin(asin, customTitle); } else if (requestId) { data = await searchEbooks(requestId, customTitle); } else { console.error('Ebook search requires either requestId or asin'); return; } } else if (hasRequestId) { const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { const audiobookAsin = fullAudiobook?.asin; data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); } setResults(data || []); } catch (err) { console.error('Search failed:', err); } }; const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') performSearch(); }; const handleDownloadClick = (torrent: TorrentResult) => { setConfirmTorrent(torrent); }; const handleConfirmDownload = async () => { if (!confirmTorrent) return; try { if (isEbookMode) { if (useAsinMode && asin) { await selectEbookByAsin(asin, confirmTorrent); } else if (requestId) { await selectEbook(requestId, confirmTorrent); } else { throw new Error('Request ID or ASIN required for ebook selection'); } } else if (hasRequestId) { await selectTorrent(requestId, confirmTorrent); } else { if (!fullAudiobook) throw new Error('Audiobook data required to create request'); await requestWithTorrent(fullAudiobook, confirmTorrent); } onSuccess?.(); setConfirmTorrent(null); onClose(); } catch (err) { console.error('Failed to download:', err); setConfirmTorrent(null); } }; // UI text based on mode const modalTitle = isEbookMode ? 'Find Ebook' : 'Find Audiobook'; const noResultsText = isEbookMode ? 'No ebooks found' : 'No results found'; const resultCountText = (count: number) => isEbookMode ? `${count} ebook${count !== 1 ? 's' : ''} found` : `${count} result${count !== 1 ? 's' : ''} found`; const confirmModalTitle = isEbookMode ? 'Download Ebook' : 'Confirm Download'; if (!isOpen || !mounted) return null; const modalContent = (
e.stopPropagation()} > {/* Header */}

{modalTitle}

{/* Scrollable Content */}
{/* Search Bar */}
setSearchTitle(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="Search title..." disabled={isSearching} className="flex-1 bg-transparent outline-none text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 min-w-0" /> {isSearching ? (
) : ( )}

by {audiobook.author}

{/* Error */} {error && (

{error}

)} {/* Loading Skeleton */} {isSearching && (
{skeletonRows.map((widths, i) => (
))}
)} {/* Empty State */} {!isSearching && results.length === 0 && !error && (

{noResultsText}

Try adjusting your search terms

)} {/* Results List */} {!isSearching && results.length > 0 && (
{results.map((result) => { const score = Math.round(result.score); const style = getScoreStyle(score); const isUsenet = result.protocol === 'usenet'; const isAnnasArchive = isEbookMode && result.source === 'annas_archive'; const displayFormat = result.format || result.ebookFormat; return (
{/* Score Badge */}
{score}
{/* Content */}
{/* Title Row */} {/* Metadata Row */}
{/* Rank */} #{result.rank} · {/* Indexer / Source */} {isAnnasArchive ? ( Anna's Archive ) : ( {result.indexer} )} {/* Size */} {result.size > 0 && ( <> · {formatSize(result.size)} )} {/* Format */} {displayFormat && ( <> · {displayFormat} )} {/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */} {!isAnnasArchive && ( <> · {isUsenet ? ( NZB ) : ( {result.seeders ?? 0} )} )} {/* Age */} {result.publishDate && ( <> · {formatAge(result.publishDate)} )} {/* Bonus Points */} {result.bonusPoints > 0 && ( <> · +{Math.round(result.bonusPoints)} )}
{/* Action Button */}
); })}
)}
{/* Sticky Footer */} {!isSearching && results.length > 0 && (

{resultCountText(results.length)}

)} {/* Inline Confirmation Overlay */} {confirmTorrent && (
!isDownloading && setConfirmTorrent(null)} >
e.stopPropagation()} > {/* Confirm Header */}

{confirmModalTitle}

This will start the download

{/* Selected Item Preview */}

{confirmTorrent.title}

{confirmTorrent.indexer} {confirmTorrent.size > 0 && ( <> · {formatSize(confirmTorrent.size)} )} {confirmTorrent.format && ( <> · {confirmTorrent.format} )} {confirmTorrent.protocol === 'usenet' ? ( <> · NZB ) : confirmTorrent.seeders !== undefined && ( <> · {confirmTorrent.seeders} seeds )}
{/* Confirm Actions */}
)}
); return createPortal(modalContent, document.body); }