From ff07ccfdb0bfe26c6a040d7b3c34ed1638199864 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Feb 2026 03:05:23 -0500 Subject: [PATCH] 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. --- src/app/admin/page.tsx | 21 +- .../api/admin/requests/[id]/approve/route.ts | 95 +++- .../audiobooks/[asin]/ebook-status/route.ts | 113 +++++ .../audiobooks/[asin]/fetch-ebook/route.ts | 336 ++++++++++++ .../[asin]/interactive-search-ebook/route.ts | 477 ++++++++++++++++++ .../audiobooks/[asin]/select-ebook/route.ts | 445 ++++++++++++++++ .../audiobooks/AudiobookDetailsModal.tsx | 208 +++++++- .../InteractiveTorrentSearchModal.tsx | 48 +- src/lib/hooks/useRequests.ts | 159 ++++++ src/lib/utils/audiobook-matcher.ts | 3 +- 10 files changed, 1858 insertions(+), 47 deletions(-) create mode 100644 src/app/api/audiobooks/[asin]/ebook-status/route.ts create mode 100644 src/app/api/audiobooks/[asin]/fetch-ebook/route.ts create mode 100644 src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts create mode 100644 src/app/api/audiobooks/[asin]/select-ebook/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4d3c758..a018a03 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -18,6 +18,7 @@ import { useState } from 'react'; interface PendingApprovalRequest { id: string; createdAt: string; + type: 'audiobook' | 'ebook'; audiobook: { title: string; author: string; @@ -146,9 +147,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest {/* Book Info */}
-

- {request.audiobook.title} -

+
+

+ {request.audiobook.title} +

+ {request.type === 'ebook' && ( + + Ebook + + )} +

{request.audiobook.author}

diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index f7eea6a..7f1d758 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -76,26 +76,67 @@ export async function POST( // Update request based on action if (action === 'approve') { const jobQueue = getJobQueueService(); + const isEbookRequest = existingRequest.type === 'ebook'; // Check if request has a pre-selected torrent (from interactive search) if (existingRequest.selectedTorrent) { + const selectedTorrent = existingRequest.selectedTorrent as any; + // User pre-selected a specific torrent - download that torrent directly logger.info(`Request ${id} has pre-selected torrent, starting download`, { requestId: id, userId: existingRequest.userId, adminId: req.user.sub, + type: existingRequest.type, + source: selectedTorrent.source, }); - // Trigger download job with pre-selected torrent - await jobQueue.addDownloadJob( - existingRequest.id, - { - id: existingRequest.audiobook.id, - title: existingRequest.audiobook.title, - author: existingRequest.audiobook.author, - }, - existingRequest.selectedTorrent as any - ); + // Handle ebook requests with Anna's Archive source differently + if (isEbookRequest && selectedTorrent.source === 'annas_archive') { + // Create download history record for Anna's Archive + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId: existingRequest.id, + indexerName: "Anna's Archive", + torrentName: `${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`, + torrentSizeBytes: null, + qualityScore: selectedTorrent.score || 100, + selected: true, + downloadClient: 'direct', + downloadStatus: 'queued', + }, + }); + + // Store all download URLs for retry purposes + if (selectedTorrent.downloadUrls && selectedTorrent.downloadUrls.length > 0) { + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + torrentUrl: JSON.stringify(selectedTorrent.downloadUrls), + }, + }); + } + + // Trigger direct download job for Anna's Archive + await jobQueue.addStartDirectDownloadJob( + existingRequest.id, + downloadHistory.id, + selectedTorrent.downloadUrl, + `${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`, + undefined + ); + } else { + // Trigger download job with pre-selected torrent (audiobook or indexer ebook) + await jobQueue.addDownloadJob( + existingRequest.id, + { + id: existingRequest.audiobook.id, + title: existingRequest.audiobook.title, + author: existingRequest.audiobook.author, + }, + selectedTorrent + ); + } // Update status to 'downloading' and clear selectedTorrent const updatedRequest = await prisma.request.update({ @@ -119,7 +160,7 @@ export async function POST( await jobQueue.addNotificationJob( 'request_approved', updatedRequest.id, - existingRequest.audiobook.title, + isEbookRequest ? `${existingRequest.audiobook.title} (Ebook)` : existingRequest.audiobook.title, existingRequest.audiobook.author, existingRequest.user.plexUsername || 'Unknown User' ).catch((error) => { @@ -131,6 +172,7 @@ export async function POST( userId: updatedRequest.userId, audiobookTitle: existingRequest.audiobook.title, adminId: req.user.sub, + type: existingRequest.type, }); return NextResponse.json({ @@ -144,6 +186,7 @@ export async function POST( requestId: id, userId: existingRequest.userId, adminId: req.user.sub, + type: existingRequest.type, }); const updatedRequest = await prisma.request.update({ @@ -160,19 +203,28 @@ export async function POST( }, }); - // Trigger search job - await jobQueue.addSearchJob(updatedRequest.id, { - id: updatedRequest.audiobook.id, - title: updatedRequest.audiobook.title, - author: updatedRequest.audiobook.author, - asin: updatedRequest.audiobook.audibleAsin || undefined, - }); + // Trigger appropriate search job based on request type + if (isEbookRequest) { + await jobQueue.addSearchEbookJob(updatedRequest.id, { + id: updatedRequest.audiobook.id, + title: updatedRequest.audiobook.title, + author: updatedRequest.audiobook.author, + asin: updatedRequest.audiobook.audibleAsin || undefined, + }); + } else { + await jobQueue.addSearchJob(updatedRequest.id, { + id: updatedRequest.audiobook.id, + title: updatedRequest.audiobook.title, + author: updatedRequest.audiobook.author, + asin: updatedRequest.audiobook.audibleAsin || undefined, + }); + } // Send notification for manual approval await jobQueue.addNotificationJob( 'request_approved', updatedRequest.id, - updatedRequest.audiobook.title, + isEbookRequest ? `${updatedRequest.audiobook.title} (Ebook)` : updatedRequest.audiobook.title, updatedRequest.audiobook.author, updatedRequest.user.plexUsername || 'Unknown User' ).catch((error) => { @@ -184,11 +236,14 @@ export async function POST( userId: updatedRequest.userId, audiobookTitle: updatedRequest.audiobook.title, adminId: req.user.sub, + type: existingRequest.type, }); return NextResponse.json({ success: true, - message: 'Request approved and search job triggered', + message: isEbookRequest + ? 'Ebook request approved and ebook search job triggered' + : 'Request approved and search job triggered', request: updatedRequest, }); } diff --git a/src/app/api/audiobooks/[asin]/ebook-status/route.ts b/src/app/api/audiobooks/[asin]/ebook-status/route.ts new file mode 100644 index 0000000..f19d1de --- /dev/null +++ b/src/app/api/audiobooks/[asin]/ebook-status/route.ts @@ -0,0 +1,113 @@ +/** + * Component: Ebook Status API Route + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Returns ebook availability status for a specific audiobook + * Used by AudiobookDetailsModal to determine if ebook buttons should be shown + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Audiobooks.EbookStatus'); + +// Statuses that indicate an active/in-progress ebook request +const ACTIVE_EBOOK_STATUSES = [ + 'pending', + 'awaiting_approval', + 'searching', + 'downloading', + 'processing', + 'downloaded', + 'available', +]; + +/** + * GET /api/audiobooks/[asin]/ebook-status + * Returns whether ebook sources are enabled and if an active ebook request exists + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { asin } = await params; + + if (!asin || asin.length !== 10) { + return NextResponse.json( + { error: 'Valid ASIN is required' }, + { status: 400 } + ); + } + + // Check which ebook sources are enabled + const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([ + prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }), + ]); + + // Legacy migration: check old key if new keys don't exist + const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' || + (annasArchiveConfig === null && legacyConfig?.value === 'true'); + const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true'; + const ebookSourcesEnabled = isAnnasArchiveEnabled || isIndexerSearchEnabled; + + // If no ebook sources enabled, return early + if (!ebookSourcesEnabled) { + return NextResponse.json({ + ebookSourcesEnabled: false, + hasActiveEbookRequest: false, + existingEbookStatus: null, + }); + } + + // Find the audiobook by ASIN + const audiobook = await prisma.audiobook.findFirst({ + where: { audibleAsin: asin }, + select: { id: true }, + }); + + if (!audiobook) { + // Audiobook not in database - that's fine, just no ebook request possible + return NextResponse.json({ + ebookSourcesEnabled: true, + hasActiveEbookRequest: false, + existingEbookStatus: null, + }); + } + + // Check for any active ebook request for this audiobook + const existingEbookRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'ebook', + deletedAt: null, + status: { in: ACTIVE_EBOOK_STATUSES }, + }, + select: { + id: true, + status: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + ebookSourcesEnabled: true, + hasActiveEbookRequest: !!existingEbookRequest, + existingEbookStatus: existingEbookRequest?.status || null, + existingEbookRequestId: existingEbookRequest?.id || null, + }); + + } catch (error) { + logger.error('Failed to get ebook status', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: 'Failed to fetch ebook status' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/api/audiobooks/[asin]/fetch-ebook/route.ts b/src/app/api/audiobooks/[asin]/fetch-ebook/route.ts new file mode 100644 index 0000000..5d0ddf8 --- /dev/null +++ b/src/app/api/audiobooks/[asin]/fetch-ebook/route.ts @@ -0,0 +1,336 @@ +/** + * Component: Fetch Ebook by ASIN API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Creates an ebook request for an available audiobook (by ASIN) + * Supports both audiobooks with parent requests and orphan audiobooks (imported outside RMAB) + * Includes approval logic for non-admin users + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Audiobooks.FetchEbook'); + +// Statuses that indicate an active/in-progress ebook request +const ACTIVE_EBOOK_STATUSES = [ + 'pending', + 'awaiting_approval', + 'searching', + 'downloading', + 'processing', + 'downloaded', + 'available', +]; + +// Statuses that allow retry +const RETRYABLE_STATUSES = ['failed', 'awaiting_search']; + +/** + * POST /api/audiobooks/[asin]/fetch-ebook + * Create an ebook request for an available audiobook + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { asin } = await params; + + if (!asin || asin.length !== 10) { + return NextResponse.json( + { error: 'Valid ASIN is required' }, + { status: 400 } + ); + } + + if (!req.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check which ebook sources are enabled + const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([ + prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }), + ]); + + const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' || + (annasArchiveConfig === null && legacyConfig?.value === 'true'); + const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true'; + + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { + return NextResponse.json( + { error: 'E-book feature is not enabled (no sources configured)' }, + { status: 400 } + ); + } + + // First, check if the audiobook is available in Plex library + // This works even for books imported outside RMAB + const audibleService = getAudibleService(); + let audibleData = null; + try { + audibleData = await audibleService.getAudiobookDetails(asin); + } catch (error) { + logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + if (!audibleData) { + return NextResponse.json( + { error: 'Audiobook not found on Audible' }, + { status: 404 } + ); + } + + // Check Plex availability using Audible metadata + const plexMatch = await findPlexMatch({ + asin, + title: audibleData.title, + author: audibleData.author, + }); + + // Find or create audiobook record + let audiobook = await prisma.audiobook.findFirst({ + where: { audibleAsin: asin }, + }); + + // Check for available request if audiobook exists in database + let availableRequest = null; + if (audiobook) { + availableRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'audiobook', + status: { in: ['downloaded', 'available'] }, + deletedAt: null, + }, + }); + } + + const isAvailable = !!availableRequest || !!plexMatch; + + if (!isAvailable) { + return NextResponse.json( + { error: 'Audiobook must be available in your library before requesting an ebook' }, + { status: 400 } + ); + } + + // If audiobook doesn't exist in database but is in Plex, create it + if (!audiobook) { + logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`); + + // Extract year from release date + let year: number | undefined; + if (audibleData.releaseDate) { + try { + const releaseYear = new Date(audibleData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + } + } catch { + // Ignore parsing errors + } + } + + audiobook = await prisma.audiobook.create({ + data: { + audibleAsin: asin, + title: audibleData.title, + author: audibleData.author, + narrator: audibleData.narrator, + description: audibleData.description, + coverArtUrl: audibleData.coverArtUrl, + year, + series: audibleData.series, + seriesPart: audibleData.seriesPart, + status: 'available', // Mark as available since it's in Plex + }, + }); + logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`); + } + + // Check for existing ebook request for this audiobook + const existingEbookRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'ebook', + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Handle existing ebook request + if (existingEbookRequest) { + // If in active status, block + if (ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status)) { + return NextResponse.json({ + success: false, + message: `E-book request already exists (status: ${existingEbookRequest.status})`, + requestId: existingEbookRequest.id, + }, { status: 409 }); + } + + // If retryable, reset and retry + if (RETRYABLE_STATUSES.includes(existingEbookRequest.status)) { + await prisma.request.update({ + where: { id: existingEbookRequest.id }, + data: { + status: 'pending', + progress: 0, + errorMessage: null, + updatedAt: new Date(), + }, + }); + + const jobQueue = getJobQueueService(); + await jobQueue.addSearchEbookJob(existingEbookRequest.id, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + asin: audiobook.audibleAsin || undefined, + }); + + logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${audiobook.title}"`); + + return NextResponse.json({ + success: true, + message: 'E-book search retried', + requestId: existingEbookRequest.id, + }); + } + } + + // Check if approval is needed for non-admin users + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + role: true, + autoApproveRequests: true, + plexUsername: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + let needsApproval = false; + + if (user.role === 'admin') { + needsApproval = false; + } else { + if (user.autoApproveRequests === true) { + needsApproval = false; + } else if (user.autoApproveRequests === false) { + needsApproval = true; + } else { + // User setting is null, check global setting + const globalConfig = await prisma.configuration.findUnique({ + where: { key: 'auto_approve_requests' }, + }); + // Default to true if not configured (backward compatibility) + const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true'; + needsApproval = !globalAutoApprove; + } + } + + const jobQueue = getJobQueueService(); + + if (needsApproval) { + // Create ebook request with awaiting_approval status + const ebookRequest = await prisma.request.create({ + data: { + userId: req.user.id, + audiobookId: audiobook.id, + type: 'ebook', + parentRequestId: availableRequest?.id || null, // Link to parent if exists + status: 'awaiting_approval', + progress: 0, + }, + }); + + // Send pending approval notification + await jobQueue.addNotificationJob( + 'request_pending_approval', + ebookRequest.id, + `${audiobook.title} (Ebook)`, + audiobook.author, + user.plexUsername || 'Unknown User' + ).catch((error) => { + logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); + }); + + logger.info(`Ebook request ${ebookRequest.id} created, awaiting admin approval`); + + return NextResponse.json({ + success: true, + message: 'Ebook request submitted for admin approval', + requestId: ebookRequest.id, + needsApproval: true, + }, { status: 201 }); + } else { + // Auto-approved - create request and start search + const ebookRequest = await prisma.request.create({ + data: { + userId: req.user.id, + audiobookId: audiobook.id, + type: 'ebook', + parentRequestId: availableRequest?.id || null, + status: 'pending', + progress: 0, + }, + }); + + logger.info(`Created ebook request ${ebookRequest.id} for "${audiobook.title}"`); + + // Trigger ebook search job + await jobQueue.addSearchEbookJob(ebookRequest.id, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + asin: audiobook.audibleAsin || undefined, + }); + + // Send approved notification + await jobQueue.addNotificationJob( + 'request_approved', + ebookRequest.id, + `${audiobook.title} (Ebook)`, + audiobook.author, + user.plexUsername || 'Unknown User' + ).catch((error) => { + logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); + }); + + logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`); + + return NextResponse.json({ + success: true, + message: 'E-book request created and search started', + requestId: ebookRequest.id, + needsApproval: false, + }, { status: 201 }); + } + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts b/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts new file mode 100644 index 0000000..86668f8 --- /dev/null +++ b/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts @@ -0,0 +1,477 @@ +/** + * Component: Interactive Search Ebook by ASIN API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Searches for ebooks from multiple sources (Anna's Archive + Indexers) + * Returns combined results for user selection in interactive modal + * User-accessible endpoint (not admin-only) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getConfigService } from '@/lib/services/config.service'; +import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; +import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm'; +import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping'; +import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { RMABLogger } from '@/lib/utils/logger'; +import { + searchByAsin, + searchByTitle, + getSlowDownloadLinks, +} from '@/lib/services/ebook-scraper'; + +const logger = RMABLogger.create('API.Audiobooks.InteractiveSearchEbook'); + +// Statuses that indicate an active/in-progress ebook request +const ACTIVE_EBOOK_STATUSES = [ + 'pending', + 'awaiting_approval', + 'searching', + 'downloading', + 'processing', + 'downloaded', + 'available', +]; + +// Statuses that allow retry via interactive search +const RETRYABLE_STATUSES = ['failed', 'awaiting_search']; + +// Unified result type for frontend +export interface EbookSearchResult { + guid: string; + title: string; + size: number; + seeders?: number; + indexer: string; + indexerId?: number; + publishDate: Date; + downloadUrl: string; + infoUrl?: string; + protocol?: string; + + score: number; + finalScore: number; + bonusPoints: number; + bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>; + rank: number; + breakdown: { + formatScore: number; + sizeScore: number; + seederScore: number; + matchScore: number; + totalScore: number; + notes: string[]; + }; + + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; +} + +/** + * POST /api/audiobooks/[asin]/interactive-search-ebook + * Search for ebooks and return results for user selection + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { asin } = await params; + const body = await request.json().catch(() => ({})); + const customTitle = body.customTitle as string | undefined; + + if (!asin || asin.length !== 10) { + return NextResponse.json( + { error: 'Valid ASIN is required' }, + { status: 400 } + ); + } + + // First, fetch audiobook data from Audible (works for books imported outside RMAB) + const audibleService = getAudibleService(); + let audibleData = null; + try { + audibleData = await audibleService.getAudiobookDetails(asin); + } catch (error) { + logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + if (!audibleData) { + return NextResponse.json( + { error: 'Audiobook not found on Audible' }, + { status: 404 } + ); + } + + // Check Plex availability using Audible metadata + const plexMatch = await findPlexMatch({ + asin, + title: audibleData.title, + author: audibleData.author, + }); + + // Find or create audiobook record + let audiobook = await prisma.audiobook.findFirst({ + where: { audibleAsin: asin }, + }); + + // Check for available request if audiobook exists in database + let availableRequest = null; + if (audiobook) { + availableRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'audiobook', + status: { in: ['downloaded', 'available'] }, + deletedAt: null, + }, + }); + } + + const isAvailable = !!availableRequest || !!plexMatch; + + if (!isAvailable) { + return NextResponse.json( + { error: 'Audiobook must be available in your library before searching for ebooks' }, + { status: 400 } + ); + } + + // If audiobook doesn't exist in database but is in Plex, create it + if (!audiobook) { + logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`); + + // Extract year from release date + let year: number | undefined; + if (audibleData.releaseDate) { + try { + const releaseYear = new Date(audibleData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + } + } catch { + // Ignore parsing errors + } + } + + audiobook = await prisma.audiobook.create({ + data: { + audibleAsin: asin, + title: audibleData.title, + author: audibleData.author, + narrator: audibleData.narrator, + description: audibleData.description, + coverArtUrl: audibleData.coverArtUrl, + year, + series: audibleData.series, + seriesPart: audibleData.seriesPart, + status: 'available', + }, + }); + logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`); + } + + // Check for existing non-retryable ebook request + const existingEbookRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'ebook', + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (existingEbookRequest && + ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status) && + !RETRYABLE_STATUSES.includes(existingEbookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${existingEbookRequest.status})`, + existingRequestId: existingEbookRequest.id, + }, { status: 400 }); + } + + // Get ebook configuration + const configService = getConfigService(); + const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([ + configService.get('ebook_annas_archive_enabled'), + configService.get('ebook_indexer_search_enabled'), + configService.get('ebook_sidecar_preferred_format'), + configService.get('ebook_sidecar_base_url'), + configService.get('ebook_sidecar_flaresolverr_url'), + ]); + + const isAnnasArchiveEnabled = annasArchiveEnabled === 'true'; + const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; + const format = preferredFormat || 'epub'; + const annasBaseUrl = baseUrl || 'https://annas-archive.li'; + + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { + return NextResponse.json( + { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, + { status: 400 } + ); + } + + const searchTitle = customTitle || audiobook.title; + + logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`); + logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`); + + // Search both sources in parallel + const searchPromises: Promise[] = []; + + if (isAnnasArchiveEnabled) { + searchPromises.push( + searchAnnasArchiveForInteractive( + audiobook.audibleAsin || undefined, + searchTitle, + audiobook.author, + format, + annasBaseUrl, + flaresolverrUrl || undefined + ).catch((err) => { + logger.error(`Anna's Archive search failed: ${err.message}`); + return null; + }) + ); + } + + if (isIndexerSearchEnabled) { + searchPromises.push( + searchIndexersForInteractive( + searchTitle, + audiobook.author, + format + ).catch((err) => { + logger.error(`Indexer search failed: ${err.message}`); + return null; + }) + ); + } + + const searchResults = await Promise.all(searchPromises); + + // Combine results: Anna's Archive first (if found), then ranked indexer results + const combinedResults: EbookSearchResult[] = []; + let rank = 1; + + // Add Anna's Archive result first (if enabled and found) + if (isAnnasArchiveEnabled && searchResults[0]) { + const annasResults = searchResults[0]; + for (const result of annasResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + // Add indexer results (already ranked) + const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0; + if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) { + const indexerResults = searchResults[indexerResultsIndex]; + for (const result of indexerResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + logger.info(`Found ${combinedResults.length} total ebook results`); + + return NextResponse.json({ + results: combinedResults, + searchTitle, + preferredFormat: format, + audiobookId: audiobook.id, + }); + + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); +} + +/** + * Search Anna's Archive and return normalized results + */ +async function searchAnnasArchiveForInteractive( + asin: string | undefined, + title: string, + author: string, + preferredFormat: string, + baseUrl: string, + flaresolverrUrl?: string +): Promise { + let md5: string | null = null; + let searchMethod: 'asin' | 'title' = 'title'; + + // Try ASIN search first + if (asin) { + logger.info(`Searching Anna's Archive by ASIN: ${asin}`); + md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + searchMethod = 'asin'; + logger.info(`Found via ASIN: ${md5}`); + } + } + + // Fallback to title search + if (!md5) { + logger.info(`Searching Anna's Archive by title: "${title}"`); + md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + logger.info(`Found via title: ${md5}`); + } + } + + if (!md5) { + logger.info('No results from Anna\'s Archive'); + return []; + } + + // Get download links + const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl); + + if (slowLinks.length === 0) { + logger.warn(`Found MD5 ${md5} but no download links available`); + return []; + } + + // Return as normalized result - always score 100 for Anna's Archive + const score = 100; + + return [{ + guid: `annas-archive-${md5}`, + title: `${title} - ${author}`, + size: 0, + seeders: 999, + indexer: "Anna's Archive", + publishDate: new Date(), + downloadUrl: slowLinks[0], + infoUrl: `${baseUrl}/md5/${md5}`, + + score, + finalScore: score, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 10, + sizeScore: 15, + seederScore: 15, + matchScore: 60, + totalScore: score, + notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"], + }, + + source: 'annas_archive', + format: preferredFormat, + md5, + downloadUrls: slowLinks, + }]; +} + +/** + * Search indexers and return ranked results + */ +async function searchIndexersForInteractive( + title: string, + author: string, + preferredFormat: string +): Promise { + const configService = getConfigService(); + + // Get indexer configuration + const indexersConfigStr = await configService.get('prowlarr_indexers'); + if (!indexersConfigStr) { + logger.warn('No indexers configured'); + return []; + } + + const indexersConfig = JSON.parse(indexersConfigStr); + if (indexersConfig.length === 0) { + logger.warn('No indexers enabled'); + return []; + } + + // Build indexer priorities map + const indexerPriorities = new Map( + indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10]) + ); + + // Get flag configurations + const flagConfigStr = await configService.get('indexer_flag_config'); + const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; + + // Group indexers by ebook categories + const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + + logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`); + + // Get Prowlarr service + const prowlarr = await getProwlarrService(); + + // Search each group and combine results + const allResults = []; + + for (const group of groups) { + try { + const groupResults = await prowlarr.search(title, { + categories: group.categories, + indexerIds: group.indexerIds, + minSeeders: 0, + maxResults: 100, + }); + allResults.push(...groupResults); + } catch (error) { + logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`); + } + } + + logger.info(`Found ${allResults.length} results from indexers`); + + if (allResults.length === 0) { + return []; + } + + // Rank results with ebook scoring + const rankedResults = rankEbookTorrents(allResults, { + title, + author, + preferredFormat, + }, { + indexerPriorities, + flagConfigs, + requireAuthor: false, + }); + + // Convert to unified result type + return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({ + guid: result.guid, + title: result.title, + size: result.size, + seeders: result.seeders, + indexer: result.indexer, + indexerId: result.indexerId, + publishDate: result.publishDate, + downloadUrl: result.downloadUrl, + infoUrl: result.infoUrl, + + score: result.score, + finalScore: result.finalScore, + bonusPoints: result.bonusPoints, + bonusModifiers: result.bonusModifiers, + rank: result.rank, + breakdown: result.breakdown, + + source: 'prowlarr', + format: result.ebookFormat, + protocol: result.protocol, + })); +} diff --git a/src/app/api/audiobooks/[asin]/select-ebook/route.ts b/src/app/api/audiobooks/[asin]/select-ebook/route.ts new file mode 100644 index 0000000..8b5bbf6 --- /dev/null +++ b/src/app/api/audiobooks/[asin]/select-ebook/route.ts @@ -0,0 +1,445 @@ +/** + * Component: Select Ebook by ASIN API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Creates an ebook request with a user-selected source (Anna's Archive or indexer) + * Routes to appropriate download processor based on source type + * Includes approval logic for non-admin users + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getConfigService } from '@/lib/services/config.service'; +import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Audiobooks.SelectEbook'); + +// Statuses that indicate an active/in-progress ebook request +const ACTIVE_EBOOK_STATUSES = [ + 'pending', + 'awaiting_approval', + 'searching', + 'downloading', + 'processing', + 'downloaded', + 'available', +]; + +// Statuses that allow reuse +const REUSABLE_STATUSES = ['failed', 'awaiting_search', 'pending']; + +interface SelectedEbook { + guid: string; + title: string; + size: number; + seeders: number; + indexer: string; + indexerId?: number; + downloadUrl: string; + infoUrl?: string; + score: number; + finalScore: number; + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; + protocol?: string; +} + +/** + * POST /api/audiobooks/[asin]/select-ebook + * Select and download an ebook from interactive search results + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { asin } = await params; + const body = await request.json(); + const selectedEbook = body.ebook as SelectedEbook; + + if (!asin || asin.length !== 10) { + return NextResponse.json( + { error: 'Valid ASIN is required' }, + { status: 400 } + ); + } + + if (!req.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + if (!selectedEbook) { + return NextResponse.json({ error: 'No ebook selected' }, { status: 400 }); + } + + if (!selectedEbook.source) { + return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 }); + } + + // First, fetch audiobook data from Audible (works for books imported outside RMAB) + const audibleService = getAudibleService(); + let audibleData = null; + try { + audibleData = await audibleService.getAudiobookDetails(asin); + } catch (error) { + logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + if (!audibleData) { + return NextResponse.json( + { error: 'Audiobook not found on Audible' }, + { status: 404 } + ); + } + + // Check Plex availability using Audible metadata + const plexMatch = await findPlexMatch({ + asin, + title: audibleData.title, + author: audibleData.author, + }); + + // Find or create audiobook record + let audiobook = await prisma.audiobook.findFirst({ + where: { audibleAsin: asin }, + }); + + // Check for available request if audiobook exists in database + let availableRequest = null; + if (audiobook) { + availableRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'audiobook', + status: { in: ['downloaded', 'available'] }, + deletedAt: null, + }, + }); + } + + const isAvailable = !!availableRequest || !!plexMatch; + + if (!isAvailable) { + return NextResponse.json( + { error: 'Audiobook must be available in your library before requesting an ebook' }, + { status: 400 } + ); + } + + // If audiobook doesn't exist in database but is in Plex, create it + if (!audiobook) { + logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`); + + // Extract year from release date + let year: number | undefined; + if (audibleData.releaseDate) { + try { + const releaseYear = new Date(audibleData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + } + } catch { + // Ignore parsing errors + } + } + + audiobook = await prisma.audiobook.create({ + data: { + audibleAsin: asin, + title: audibleData.title, + author: audibleData.author, + narrator: audibleData.narrator, + description: audibleData.description, + coverArtUrl: audibleData.coverArtUrl, + year, + series: audibleData.series, + seriesPart: audibleData.seriesPart, + status: 'available', + }, + }); + logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`); + } + + // Check for existing ebook request + let ebookRequest = await prisma.request.findFirst({ + where: { + audiobookId: audiobook.id, + type: 'ebook', + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Handle existing ebook request + if (ebookRequest) { + if (ACTIVE_EBOOK_STATUSES.includes(ebookRequest.status) && + !REUSABLE_STATUSES.includes(ebookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${ebookRequest.status})`, + existingRequestId: ebookRequest.id, + }, { status: 400 }); + } + } + + // Check if approval is needed for non-admin users + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + role: true, + autoApproveRequests: true, + plexUsername: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + let needsApproval = false; + + if (user.role === 'admin') { + needsApproval = false; + } else { + if (user.autoApproveRequests === true) { + needsApproval = false; + } else if (user.autoApproveRequests === false) { + needsApproval = true; + } else { + const globalConfig = await prisma.configuration.findUnique({ + where: { key: 'auto_approve_requests' }, + }); + const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true'; + needsApproval = !globalAutoApprove; + } + } + + const jobQueue = getJobQueueService(); + + if (needsApproval) { + // Create or update ebook request with awaiting_approval status + if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) { + ebookRequest = await prisma.request.update({ + where: { id: ebookRequest.id }, + data: { + status: 'awaiting_approval', + progress: 0, + errorMessage: null, + selectedTorrent: selectedEbook as any, // Store selected ebook for later + updatedAt: new Date(), + }, + }); + logger.info(`Reusing ebook request ${ebookRequest.id}, awaiting approval`); + } else { + ebookRequest = await prisma.request.create({ + data: { + userId: req.user.id, + audiobookId: audiobook.id, + type: 'ebook', + parentRequestId: availableRequest?.id || null, + status: 'awaiting_approval', + progress: 0, + selectedTorrent: selectedEbook as any, + }, + }); + logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`); + } + + // Send pending approval notification + await jobQueue.addNotificationJob( + 'request_pending_approval', + ebookRequest.id, + `${audiobook.title} (Ebook)`, + audiobook.author, + user.plexUsername || 'Unknown User' + ).catch((error) => { + logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); + }); + + return NextResponse.json({ + success: true, + message: 'Ebook request submitted for admin approval', + requestId: ebookRequest.id, + needsApproval: true, + }, { status: 201 }); + } else { + // Auto-approved - create or update request and start download + if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) { + ebookRequest = await prisma.request.update({ + where: { id: ebookRequest.id }, + data: { + status: 'searching', + progress: 0, + errorMessage: null, + updatedAt: new Date(), + }, + }); + logger.info(`Reusing existing ebook request ${ebookRequest.id}`); + } else { + ebookRequest = await prisma.request.create({ + data: { + userId: req.user.id, + audiobookId: audiobook.id, + type: 'ebook', + parentRequestId: availableRequest?.id || null, + status: 'searching', + progress: 0, + }, + }); + logger.info(`Created new ebook request ${ebookRequest.id}`); + } + + // Route to appropriate download based on source + if (selectedEbook.source === 'annas_archive') { + await handleAnnasArchiveDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } else { + await handleIndexerDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } + + // Send approved notification + await jobQueue.addNotificationJob( + 'request_approved', + ebookRequest.id, + `${audiobook.title} (Ebook)`, + audiobook.author, + user.plexUsername || 'Unknown User' + ).catch((error) => { + logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); + }); + + return NextResponse.json({ + success: true, + message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`, + requestId: ebookRequest.id, + needsApproval: false, + }); + } + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); +} + +/** + * Handle Anna's Archive download (direct HTTP) + */ +async function handleAnnasArchiveDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + const configService = getConfigService(); + const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub'; + + logger.info(`Starting Anna's Archive download for "${audiobook.title}"`); + logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`); + + // Create download history record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: "Anna's Archive", + torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + torrentSizeBytes: null, + qualityScore: selectedEbook.score, + selected: true, + downloadClient: 'direct', + downloadStatus: 'queued', + }, + }); + + // Store all download URLs for retry purposes + if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) { + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + torrentUrl: JSON.stringify(selectedEbook.downloadUrls), + }, + }); + } + + // Trigger direct download job + await jobQueue.addStartDirectDownloadJob( + requestId, + downloadHistory.id, + selectedEbook.downloadUrl, + `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + undefined + ); + + logger.info(`Queued direct download job for request ${requestId}`); +} + +/** + * Handle indexer download (torrent/NZB) + */ +async function handleIndexerDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + logger.info(`Starting indexer download for "${audiobook.title}"`); + logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`); + + const torrentForJob = { + guid: selectedEbook.guid, + title: selectedEbook.title, + size: selectedEbook.size, + seeders: selectedEbook.seeders || 0, + indexer: selectedEbook.indexer, + indexerId: selectedEbook.indexerId, + downloadUrl: selectedEbook.downloadUrl, + infoUrl: selectedEbook.infoUrl, + publishDate: new Date(), + score: selectedEbook.score, + finalScore: selectedEbook.finalScore, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 0, + totalScore: selectedEbook.score, + notes: [], + }, + protocol: selectedEbook.protocol, + }; + + await jobQueue.addDownloadJob(requestId, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + }, torrentForJob as any); + + logger.info(`Queued download job for request ${requestId}`); +} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 9b5d260..243a074 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -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(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 ( -
-
- - Available in Your Library - + <> +
+
+ + Available in Your Library + +
-
+ + {/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */} + {canShowEbookButtons && user && ( + <> + {/* Grab Ebook Button */} + + + {/* Interactive Search Ebook Button */} + + + )} + + {/* Show ebook request status if one exists */} + {ebookStatus?.hasActiveEbookRequest && ( +
+ + + + + Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval' + ? 'Pending Approval' + : ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded' + ? 'Available' + : 'In Progress'} + +
+ )} + ); } @@ -542,7 +713,7 @@ export function AudiobookDetailsModal({ {showToast && (

- ✓ Request created successfully! + ✓ {toastMessage}

)} @@ -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(
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
, document.body )} + {/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */} + {showInteractiveSearchEbook && audiobook && createPortal( +
+
+ +
+
, + document.body + )} ); } diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 9ee5ff9..10814f7 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -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(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); diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts index c57a43e..0be4a8e 100644 --- a/src/lib/hooks/useRequests.ts +++ b/src/lib/hooks/useRequests.ts @@ -482,3 +482,162 @@ export function useSelectEbook() { return { selectEbook, isLoading, error }; } + +// ==================== ASIN-based Ebook Hooks ==================== +// These hooks are used for requesting ebooks from the audiobook details modal +// where we only have an ASIN, not an existing request ID + +export interface EbookStatus { + ebookSourcesEnabled: boolean; + hasActiveEbookRequest: boolean; + existingEbookStatus: string | null; + existingEbookRequestId: string | null; +} + +export function useEbookStatus(asin: string | null) { + const { accessToken } = useAuth(); + + const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/ebook-status` : null; + + const { data, error, isLoading, mutate: revalidate } = useSWR( + endpoint, + fetcher, + { + refreshInterval: 10000, // Refresh every 10 seconds + } + ); + + return { + ebookStatus: data || null, + isLoading, + error, + revalidate, + }; +} + +export function useFetchEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEbook = async (asin: string) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/fetch-ebook`, { + method: 'POST', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to request ebook'); + } + + // Revalidate requests and ebook status + mutate((key) => typeof key === 'string' && key.includes('/api/requests')); + mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); + + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { fetchEbook, isLoading, error }; +} + +export function useInteractiveSearchEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const searchEbooks = async (asin: string, customTitle?: string) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/interactive-search-ebook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: customTitle ? JSON.stringify({ customTitle }) : undefined, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to search for ebooks'); + } + + return data.results || []; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { searchEbooks, isLoading, error }; +} + +export function useSelectEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const selectEbook = async (asin: string, ebook: any) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/select-ebook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ebook }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to download ebook'); + } + + // Revalidate requests and ebook status + mutate((key) => typeof key === 'string' && key.includes('/api/requests')); + mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); + + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { selectEbook, isLoading, error }; +} diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index 61ad68e..67ca135 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches( // Always enrich with request status (check ANY user's requests) const asins = audiobooks.map(book => book.asin); - // Get all audiobook records for these ASINs with ALL requests + // Get all audiobook records for these ASINs with ALL audiobook requests (not ebook requests) const audiobookRecords = await prisma.audiobook.findMany({ where: { audibleAsin: { in: asins }, @@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches( requests: { where: { deletedAt: null, // Only include active (non-deleted) requests + type: 'audiobook', // Only check audiobook requests, not ebook requests }, select: { id: true,