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,