mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
ff07ccfdb0
Introduce ebook-sidecar support: add new API routes for ebook workflows (ebook-status, fetch-ebook, interactive-search-ebook, select-ebook) that handle searching, selection, request creation, approval, and download routing (Anna's Archive direct downloads vs indexer downloads). Update admin approval flow to understand request.type (audiobook | ebook), handle pre-selected ebook torrents (including special handling for Anna's Archive with direct download jobs and download history), and enqueue ebook-specific search/download jobs. Frontend changes: show request type badge in admin pending approvals and augment AudiobookDetailsModal to query ebook status, start fetch/interactive ebook searches, and surface toast notifications. Also include new request lifecycle handling (retryable/active statuses, approval logic, creating audiobook records for Plex-imported books) and ranking/normalization logic for interactive ebook search results. Other: various plumbing to integrate config checks, job queue calls, and download history storage for ebook downloads.
419 lines
19 KiB
TypeScript
419 lines
19 KiB
TypeScript
/**
|
|
* 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 } from 'react';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
|
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
|
|
}
|
|
|
|
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 })[]>([]);
|
|
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
|
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
|
|
|
// 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));
|
|
|
|
// Reset search title when modal opens/closes or audiobook changes
|
|
React.useEffect(() => {
|
|
setSearchTitle(audiobook.title);
|
|
setResults([]);
|
|
}, [isOpen, audiobook.title]);
|
|
|
|
// Perform search when modal opens
|
|
React.useEffect(() => {
|
|
if (isOpen && results.length === 0) {
|
|
performSearch();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const performSearch = async () => {
|
|
// Clear existing results while searching
|
|
setResults([]);
|
|
|
|
try {
|
|
let data;
|
|
if (isEbookMode) {
|
|
// Ebook mode: search Anna's Archive + indexers
|
|
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
|
if (useAsinMode && asin) {
|
|
// ASIN-based ebook search (user flow from details modal)
|
|
data = await searchEbooksByAsin(asin, customTitle);
|
|
} else if (requestId) {
|
|
// Request ID-based ebook search (admin flow)
|
|
data = await searchEbooks(requestId, customTitle);
|
|
} else {
|
|
console.error('Ebook search requires either requestId or asin');
|
|
return;
|
|
}
|
|
} else if (hasRequestId) {
|
|
// Existing audiobook flow: search by requestId with optional custom title
|
|
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
|
data = await searchByRequestId(requestId, customTitle);
|
|
} else {
|
|
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
|
|
const audiobookAsin = fullAudiobook?.asin;
|
|
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
|
|
}
|
|
setResults(data || []);
|
|
} catch (err) {
|
|
// Error already handled by hook
|
|
console.error('Search failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
performSearch();
|
|
}
|
|
};
|
|
|
|
const handleDownloadClick = (torrent: TorrentResult) => {
|
|
setConfirmTorrent(torrent);
|
|
};
|
|
|
|
const handleConfirmDownload = async () => {
|
|
if (!confirmTorrent) return;
|
|
|
|
try {
|
|
if (isEbookMode) {
|
|
// Ebook flow
|
|
if (useAsinMode && asin) {
|
|
// ASIN-based ebook selection (user flow from details modal)
|
|
await selectEbookByAsin(asin, confirmTorrent);
|
|
} else if (requestId) {
|
|
// Request ID-based ebook selection (admin flow)
|
|
await selectEbook(requestId, confirmTorrent);
|
|
} else {
|
|
throw new Error('Request ID or ASIN required for ebook selection');
|
|
}
|
|
} else if (hasRequestId) {
|
|
// Existing audiobook flow: select torrent for existing request
|
|
await selectTorrent(requestId, confirmTorrent);
|
|
} else {
|
|
// New audiobook flow: create request with torrent
|
|
if (!fullAudiobook) {
|
|
throw new Error('Audiobook data required to create request');
|
|
}
|
|
await requestWithTorrent(fullAudiobook, confirmTorrent);
|
|
}
|
|
// Notify parent of successful selection
|
|
onSuccess?.();
|
|
// Close modals on success
|
|
setConfirmTorrent(null);
|
|
onClose();
|
|
// Request list will auto-refresh via SWR
|
|
} catch (err) {
|
|
// Error already handled by hook
|
|
console.error('Failed to download:', err);
|
|
setConfirmTorrent(null);
|
|
}
|
|
};
|
|
|
|
const formatSize = (bytes: number) => {
|
|
const gb = bytes / (1024 ** 3);
|
|
const mb = bytes / (1024 ** 2);
|
|
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
|
};
|
|
|
|
const getQualityBadgeColor = (score: number) => {
|
|
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
|
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
|
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
|
};
|
|
|
|
// UI text based on mode
|
|
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
|
|
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
|
|
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
|
|
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
|
|
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
|
|
const resultCountText = (count: number) =>
|
|
isEbookMode
|
|
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
|
|
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
|
|
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
|
|
|
|
return (
|
|
<>
|
|
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
|
|
<div className="space-y-4">
|
|
{/* Search customization - editable for ALL modes */}
|
|
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{searchLabel}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={searchTitle}
|
|
onChange={(e) => setSearchTitle(e.target.value)}
|
|
onKeyPress={handleSearchKeyPress}
|
|
placeholder={searchPlaceholder}
|
|
disabled={isSearching}
|
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
|
/>
|
|
<Button
|
|
onClick={performSearch}
|
|
disabled={isSearching || !searchTitle.trim()}
|
|
variant="primary"
|
|
size="sm"
|
|
>
|
|
Search
|
|
</Button>
|
|
</div>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isSearching && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
|
|
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* No results */}
|
|
{!isSearching && results.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
|
|
<Button onClick={performSearch} variant="outline" className="mt-4">
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results table */}
|
|
{!isSearching && results.length > 0 && (
|
|
<div className="overflow-x-auto -mx-6">
|
|
<div className="inline-block min-w-full align-middle px-6">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
|
<tr>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12">
|
|
#
|
|
</th>
|
|
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Title
|
|
</th>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24">
|
|
Size
|
|
</th>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)">
|
|
Score
|
|
</th>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers">
|
|
Bonus
|
|
</th>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20">
|
|
Seeds
|
|
</th>
|
|
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
|
|
{isEbookMode ? 'Source' : 'Indexer'}
|
|
</th>
|
|
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
|
Action
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{results.map((result) => (
|
|
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{result.rank}
|
|
</td>
|
|
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
<div className="truncate">
|
|
<a
|
|
href={result.infoUrl || result.guid}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
|
|
title={result.title}
|
|
>
|
|
{result.title}
|
|
</a>
|
|
</div>
|
|
<div className="flex gap-2 mt-1 flex-wrap">
|
|
{/* Anna's Archive badge for ebook mode */}
|
|
{isEbookMode && result.source === 'annas_archive' && (
|
|
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
|
|
Anna's Archive
|
|
</span>
|
|
)}
|
|
{result.format && (
|
|
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
|
|
{result.format}
|
|
</span>
|
|
)}
|
|
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
|
|
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
|
|
</span>
|
|
{/* Hide seeds badge for Anna's Archive results */}
|
|
{!(isEbookMode && result.source === 'annas_archive') && (
|
|
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
|
|
{result.seeders} seeds
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
|
|
{result.size > 0 ? formatSize(result.size) : '—'}
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
|
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
|
|
{Math.round(result.score)}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
|
{isEbookMode && result.source === 'annas_archive' ? (
|
|
<span className="text-gray-400">N/A</span>
|
|
) : (
|
|
<span className="flex items-center gap-1">
|
|
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
|
</svg>
|
|
{result.seeders}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
|
|
{isEbookMode && result.source === 'annas_archive' ? (
|
|
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
|
) : (
|
|
result.indexer
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
|
<Button
|
|
onClick={() => handleDownloadClick(result)}
|
|
disabled={isDownloading}
|
|
size="sm"
|
|
variant="primary"
|
|
>
|
|
Download
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer with result count */}
|
|
{!isSearching && results.length > 0 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{resultCountText(results.length)}
|
|
</p>
|
|
<Button onClick={performSearch} variant="outline" size="sm">
|
|
Refresh Results
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Confirmation Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!confirmTorrent}
|
|
onClose={() => setConfirmTorrent(null)}
|
|
onConfirm={handleConfirmDownload}
|
|
title={confirmTitle}
|
|
message={`Download "${confirmTorrent?.title}"?`}
|
|
confirmText="Download"
|
|
isLoading={isDownloading}
|
|
variant="primary"
|
|
/>
|
|
</>
|
|
);
|
|
}
|