/** * 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 { extractTitleTags } from '@/lib/utils/title-tags'; import { useIsTruncated } from '@/lib/hooks/useIsTruncated'; import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent, useInteractiveSearchEbook, useSelectEbook, useInteractiveSearchEbookByAsin, useSelectEbookByAsin, } from '@/lib/hooks/useRequests'; import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { fetchWithAuth } from '@/lib/utils/api'; import { normalizeReleaseKey } from '@/lib/utils/release-key'; interface BlockedReleaseLookup { /** normalized release key → reason text */ byKey: Map; /** release hash (torrentHash / nzbId / infoHash) → reason text */ byHash: Map; } const EMPTY_BLOCKED_LOOKUP: BlockedReleaseLookup = { byKey: new Map(), byHash: new Map(), }; 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; }; customSearchTerms?: string | null; // Optional - admin-set custom search terms override fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead onConfirm?: (torrent: TorrentResult) => Promise; // Optional - overrides default confirm handler } // 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, customSearchTerms, fullAudiobook, onSuccess, searchMode = 'audiobook', replaceIssueId, onConfirm, }: InteractiveTorrentSearchModalProps) { // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent(); // Hook for reported issue replacement flow const { replaceWithTorrent, isLoading: isReplacing, error: replaceError } = useReplaceWithTorrent(); // 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 [blockedLookup, setBlockedLookup] = useState(EMPTY_BLOCKED_LOOKUP); // Per locked decision #3, interactive search is NOT filtered — it shows // everything; we just mark blocked rows visually so admins know. The admin // endpoint enforces auth/role; non-admin users silently get a 403 and no // badges are rendered. We only attempt the fetch when we have a requestId // (the ASIN-based ebook flow has no per-request blocklist context). const canFetchBlocklist = !!requestId && isOpen; const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title); const [isCustomConfirming, setIsCustomConfirming] = useState(false); const [mounted, setMounted] = useState(false); const [expandedGuids, setExpandedGuids] = useState>(() => new Set()); // 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 = isCustomConfirming ? true : replaceIssueId ? isReplacing : isEbookMode ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); const error = replaceIssueId ? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError)) : 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(customSearchTerms || audiobook.title); setResults([]); setExpandedGuids(new Set()); }, [isOpen, audiobook.title, customSearchTerms]); // Reset blocklist lookup when modal closes; fetch when admin opens it. useEffect(() => { if (!canFetchBlocklist) { setBlockedLookup(EMPTY_BLOCKED_LOOKUP); return; } let cancelled = false; (async () => { try { const response = await fetchWithAuth( `/api/admin/blocklist/by-request/${requestId}` ); if (!response.ok) { // 403 (non-admin via API token, etc.) silently leaves badge off. return; } const data: { entries: Array<{ releaseName: string; releaseHash: string | null; reason: string }>; } = await response.json(); if (cancelled) return; const byKey = new Map(); const byHash = new Map(); for (const entry of data.entries) { byKey.set(normalizeReleaseKey(entry.releaseName), entry.reason); if (entry.releaseHash) byHash.set(entry.releaseHash.toLowerCase(), entry.reason); } setBlockedLookup({ byKey, byHash }); } catch { // Network errors — leave badge off rather than disrupt search UI. } })(); return () => { cancelled = true; }; }, [canFetchBlocklist, requestId]); // 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([]); setExpandedGuids(new Set()); 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 || 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 (onConfirm) { // Custom confirm handler (e.g., admin approve-with-torrent flow) setIsCustomConfirming(true); await onConfirm(confirmTorrent); } else if (replaceIssueId) { // Reported issue replacement flow await replaceWithTorrent(replaceIssueId, confirmTorrent); } else 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); } finally { setIsCustomConfirming(false); } }; // 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) => ( { setExpandedGuids((prev) => { const next = new Set(prev); if (next.has(result.guid)) next.delete(result.guid); else next.add(result.guid); return next; }); }} onDownload={() => handleDownloadClick(result)} /> ))}
)}
{/* 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); } function resolveBlockedReason( result: RankedTorrent & { source?: string }, lookup: BlockedReleaseLookup ): string | null { if (lookup.byKey.size === 0 && lookup.byHash.size === 0) return null; const byName = lookup.byKey.get(normalizeReleaseKey(result.title)); if (byName) return byName; if (result.infoHash) { const byHash = lookup.byHash.get(result.infoHash.toLowerCase()); if (byHash) return byHash; } return null; } interface ResultRowProps { result: RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string }; isEbookMode: boolean; isExpanded: boolean; isDownloading: boolean; /** Non-null when this result matches a blocklist entry for the current request. */ blockedReason: string | null; onToggleExpand: () => void; onDownload: () => void; } function ResultRow({ result, isEbookMode, isExpanded, isDownloading, blockedReason, onToggleExpand, onDownload, }: ResultRowProps) { 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; const { tags } = extractTitleTags(result.title); const displayFormatLower = (displayFormat ?? '').toLowerCase(); const chipTags = tags.filter((t) => t.toLowerCase() !== displayFormatLower); const titleRef = useRef(null); const isTruncated = useIsTruncated(titleRef); // Why: keep chevron rendered while expanded so users can collapse — once // expanded, scrollWidth no longer exceeds clientWidth and isTruncated flips false. const showChevron = isTruncated || isExpanded; return (
{/* Score Badge */}
{score}
{/* Content */}
{/* Blocked badge — informational, NOT a warning. Per zach.md "displayed source data stays true to source" — the badge adds context, the title above is rendered verbatim either way. */} {blockedReason && (
Already blocked — {blockedReason}
)} {/* Title Row */}
{result.title} {showChevron && ( )}
{/* Metadata Row */}
{/* Rank */} #{result.rank} · {/* Indexer / Source */} {isAnnasArchive ? ( Anna's Archive ) : ( {result.indexer} )} {/* Size */} {result.size > 0 && ( <> · {formatSize(result.size)} )} {/* Format */} {displayFormat && ( <> · {displayFormat} )} {/* Title tag chips (language/edition/etc.) */} {chipTags.map((tag) => ( {tag} ))} {/* 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 */}
); }