/** * 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, customSearchTerms: availableRequest?.customSearchTerms || null, }, }); 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, customSearchTerms: availableRequest?.customSearchTerms || null, }, }); 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}`); }