mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add ebook-sidecar APIs and UI integration
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.
This commit is contained in:
@@ -11,7 +11,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { StatusBadge } from '@/components/requests/StatusBadge';
|
||||
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCreateRequest } from '@/lib/hooks/useRequests';
|
||||
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
|
||||
@@ -39,12 +39,21 @@ export function AudiobookDetailsModal({
|
||||
const { user } = useAuth();
|
||||
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('Request created successfully!');
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
|
||||
// Determine if ebook buttons should be shown
|
||||
const canShowEbookButtons = isAvailable &&
|
||||
ebookStatus?.ebookSourcesEnabled &&
|
||||
!ebookStatus?.hasActiveEbookRequest;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -68,6 +77,7 @@ export function AudiobookDetailsModal({
|
||||
|
||||
try {
|
||||
await createRequest(audiobook);
|
||||
setToastMessage('Request created successfully!');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
@@ -103,6 +113,53 @@ export function AudiobookDetailsModal({
|
||||
onRequestSuccess?.();
|
||||
};
|
||||
|
||||
const handleFetchEbook = async () => {
|
||||
if (!user) {
|
||||
setRequestError('Please log in to request ebooks');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchEbook(asin);
|
||||
revalidateEbookStatus();
|
||||
|
||||
if (result.needsApproval) {
|
||||
setToastMessage('Ebook request submitted for approval!');
|
||||
} else {
|
||||
setToastMessage('Ebook search started!');
|
||||
}
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setRequestError(err instanceof Error ? err.message : 'Failed to request ebook');
|
||||
setTimeout(() => setRequestError(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
if (!user) {
|
||||
setRequestError('Please log in to request ebooks');
|
||||
return;
|
||||
}
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbookClose = () => {
|
||||
setShowInteractiveSearchEbook(false);
|
||||
revalidateEbookStatus();
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbookSuccess = () => {
|
||||
revalidateEbookStatus();
|
||||
setToastMessage('Ebook download started!');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -419,13 +476,127 @@ export function AudiobookDetailsModal({
|
||||
// Check if book is already available in library or completed status
|
||||
if (isAvailable || requestStatus === 'completed') {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
|
||||
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
||||
Available in Your Library
|
||||
</span>
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
|
||||
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
||||
Available in Your Library
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */}
|
||||
{canShowEbookButtons && user && (
|
||||
<>
|
||||
{/* Grab Ebook Button */}
|
||||
<button
|
||||
onClick={handleFetchEbook}
|
||||
disabled={isFetchingEbook}
|
||||
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
|
||||
}}
|
||||
title="Grab Ebook"
|
||||
aria-label="Grab Ebook"
|
||||
>
|
||||
{isFetchingEbook ? (
|
||||
<div className="animate-spin w-6 h-6 border-2 border-current border-t-transparent rounded-full" style={{ color: '#f16f19' }} />
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Grab Ebook
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Interactive Search Ebook Button */}
|
||||
<button
|
||||
onClick={handleInteractiveSearchEbook}
|
||||
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
|
||||
}}
|
||||
title="Search Ebook Sources"
|
||||
aria-label="Search Ebook Sources"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Search Ebook Sources
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show ebook request status if one exists */}
|
||||
{ebookStatus?.hasActiveEbookRequest && (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
color: '#f16f19',
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval'
|
||||
? 'Pending Approval'
|
||||
: ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded'
|
||||
? 'Available'
|
||||
: 'In Progress'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,7 +713,7 @@ export function AudiobookDetailsModal({
|
||||
{showToast && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<p className="text-green-800 dark:text-green-200 text-center font-medium">
|
||||
✓ Request created successfully!
|
||||
✓ {toastMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -555,7 +726,7 @@ export function AudiobookDetailsModal({
|
||||
return (
|
||||
<>
|
||||
{createPortal(modalContent, document.body)}
|
||||
{/* Interactive Search Modal - render with higher z-index to appear above details modal */}
|
||||
{/* Interactive Search Modal (Audiobook) - render with higher z-index to appear above details modal */}
|
||||
{showInteractiveSearch && audiobook && createPortal(
|
||||
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
|
||||
<div style={{ pointerEvents: 'auto' }}>
|
||||
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */}
|
||||
{showInteractiveSearchEbook && audiobook && createPortal(
|
||||
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
|
||||
<div style={{ pointerEvents: 'auto' }}>
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearchEbook}
|
||||
onClose={handleInteractiveSearchEbookClose}
|
||||
onSuccess={handleInteractiveSearchEbookSuccess}
|
||||
asin={asin}
|
||||
audiobook={{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
useRequestWithTorrent,
|
||||
useInteractiveSearchEbook,
|
||||
useSelectEbook,
|
||||
useInteractiveSearchEbookByAsin,
|
||||
useSelectEbookByAsin,
|
||||
} from '@/lib/hooks/useRequests';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
|
||||
@@ -28,6 +30,7 @@ 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;
|
||||
@@ -41,6 +44,7 @@ export function InteractiveTorrentSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
asin,
|
||||
audiobook,
|
||||
fullAudiobook,
|
||||
onSuccess,
|
||||
@@ -54,10 +58,14 @@ export function InteractiveTorrentSearchModal({
|
||||
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
||||
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
||||
|
||||
// Hooks for ebook flow
|
||||
// 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);
|
||||
@@ -65,16 +73,18 @@ export function InteractiveTorrentSearchModal({
|
||||
// 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
|
||||
? isSearchingEbooks
|
||||
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
|
||||
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
|
||||
const isDownloading = isEbookMode
|
||||
? isSelectingEbook
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const error = isEbookMode
|
||||
? (searchEbooksError || selectEbookError)
|
||||
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
|
||||
: (hasRequestId
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError));
|
||||
@@ -100,20 +110,25 @@ export function InteractiveTorrentSearchModal({
|
||||
let data;
|
||||
if (isEbookMode) {
|
||||
// Ebook mode: search Anna's Archive + indexers
|
||||
if (!requestId) {
|
||||
console.error('Ebook search requires a requestId');
|
||||
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;
|
||||
}
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
data = await searchEbooks(requestId, customTitle);
|
||||
} 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 asin = fullAudiobook?.asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
|
||||
const audiobookAsin = fullAudiobook?.asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
|
||||
}
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
@@ -137,11 +152,16 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
try {
|
||||
if (isEbookMode) {
|
||||
// Ebook flow: select ebook for existing audiobook request
|
||||
if (!requestId) {
|
||||
throw new Error('Request ID required for ebook selection');
|
||||
// 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');
|
||||
}
|
||||
await selectEbook(requestId, confirmTorrent);
|
||||
} else if (hasRequestId) {
|
||||
// Existing audiobook flow: select torrent for existing request
|
||||
await selectTorrent(requestId, confirmTorrent);
|
||||
|
||||
Reference in New Issue
Block a user