Add ebook-sidecar APIs and UI integration

Introduce ebook-sidecar support: add new API routes for ebook workflows (ebook-status, fetch-ebook, interactive-search-ebook, select-ebook) that handle searching, selection, request creation, approval, and download routing (Anna's Archive direct downloads vs indexer downloads).

Update admin approval flow to understand request.type (audiobook | ebook), handle pre-selected ebook torrents (including special handling for Anna's Archive with direct download jobs and download history), and enqueue ebook-specific search/download jobs.

Frontend changes: show request type badge in admin pending approvals and augment AudiobookDetailsModal to query ebook status, start fetch/interactive ebook searches, and surface toast notifications. Also include new request lifecycle handling (retryable/active statuses, approval logic, creating audiobook records for Plex-imported books) and ranking/normalization logic for interactive ebook search results.

Other: various plumbing to integrate config checks, job queue calls, and download history storage for ebook downloads.
This commit is contained in:
kikootwo
2026-02-03 03:05:23 -05:00
parent a17473e204
commit ff07ccfdb0
10 changed files with 1858 additions and 47 deletions
+15
View File
@@ -18,6 +18,7 @@ import { useState } from 'react';
interface PendingApprovalRequest { interface PendingApprovalRequest {
id: string; id: string;
createdAt: string; createdAt: string;
type: 'audiobook' | 'ebook';
audiobook: { audiobook: {
title: string; title: string;
author: string; author: string;
@@ -146,9 +147,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{/* Book Info */} {/* Book Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate"> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{request.audiobook.title} {request.audiobook.title}
</h3> </h3>
{request.type === 'ebook' && (
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0"
style={{
backgroundColor: 'rgba(241, 111, 25, 0.15)',
color: '#f16f19',
border: '1px solid rgba(241, 111, 25, 0.3)',
}}
>
Ebook
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate"> <p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{request.audiobook.author} {request.audiobook.author}
</p> </p>
@@ -76,17 +76,57 @@ export async function POST(
// Update request based on action // Update request based on action
if (action === 'approve') { if (action === 'approve') {
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
const isEbookRequest = existingRequest.type === 'ebook';
// Check if request has a pre-selected torrent (from interactive search) // Check if request has a pre-selected torrent (from interactive search)
if (existingRequest.selectedTorrent) { if (existingRequest.selectedTorrent) {
const selectedTorrent = existingRequest.selectedTorrent as any;
// User pre-selected a specific torrent - download that torrent directly // User pre-selected a specific torrent - download that torrent directly
logger.info(`Request ${id} has pre-selected torrent, starting download`, { logger.info(`Request ${id} has pre-selected torrent, starting download`, {
requestId: id, requestId: id,
userId: existingRequest.userId, userId: existingRequest.userId,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type,
source: selectedTorrent.source,
}); });
// Trigger download job with pre-selected torrent // 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( await jobQueue.addDownloadJob(
existingRequest.id, existingRequest.id,
{ {
@@ -94,8 +134,9 @@ export async function POST(
title: existingRequest.audiobook.title, title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author, author: existingRequest.audiobook.author,
}, },
existingRequest.selectedTorrent as any selectedTorrent
); );
}
// Update status to 'downloading' and clear selectedTorrent // Update status to 'downloading' and clear selectedTorrent
const updatedRequest = await prisma.request.update({ const updatedRequest = await prisma.request.update({
@@ -119,7 +160,7 @@ export async function POST(
await jobQueue.addNotificationJob( await jobQueue.addNotificationJob(
'request_approved', 'request_approved',
updatedRequest.id, updatedRequest.id,
existingRequest.audiobook.title, isEbookRequest ? `${existingRequest.audiobook.title} (Ebook)` : existingRequest.audiobook.title,
existingRequest.audiobook.author, existingRequest.audiobook.author,
existingRequest.user.plexUsername || 'Unknown User' existingRequest.user.plexUsername || 'Unknown User'
).catch((error) => { ).catch((error) => {
@@ -131,6 +172,7 @@ export async function POST(
userId: updatedRequest.userId, userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title, audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type,
}); });
return NextResponse.json({ return NextResponse.json({
@@ -144,6 +186,7 @@ export async function POST(
requestId: id, requestId: id,
userId: existingRequest.userId, userId: existingRequest.userId,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type,
}); });
const updatedRequest = await prisma.request.update({ const updatedRequest = await prisma.request.update({
@@ -160,19 +203,28 @@ export async function POST(
}, },
}); });
// Trigger search job // 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, { await jobQueue.addSearchJob(updatedRequest.id, {
id: updatedRequest.audiobook.id, id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title, title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author, author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined, asin: updatedRequest.audiobook.audibleAsin || undefined,
}); });
}
// Send notification for manual approval // Send notification for manual approval
await jobQueue.addNotificationJob( await jobQueue.addNotificationJob(
'request_approved', 'request_approved',
updatedRequest.id, updatedRequest.id,
updatedRequest.audiobook.title, isEbookRequest ? `${updatedRequest.audiobook.title} (Ebook)` : updatedRequest.audiobook.title,
updatedRequest.audiobook.author, updatedRequest.audiobook.author,
updatedRequest.user.plexUsername || 'Unknown User' updatedRequest.user.plexUsername || 'Unknown User'
).catch((error) => { ).catch((error) => {
@@ -184,11 +236,14 @@ export async function POST(
userId: updatedRequest.userId, userId: updatedRequest.userId,
audiobookTitle: updatedRequest.audiobook.title, audiobookTitle: updatedRequest.audiobook.title,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, 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, request: updatedRequest,
}); });
} }
@@ -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 }
);
}
});
}
@@ -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 }
);
}
});
}
@@ -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<EbookSearchResult[] | null>[] = [];
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<EbookSearchResult[]> {
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<EbookSearchResult[]> {
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<number, number>(
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,
}));
}
@@ -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<typeof getJobQueueService>
) {
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<typeof getJobQueueService>
) {
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}`);
}
@@ -11,7 +11,7 @@ import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { StatusBadge } from '@/components/requests/StatusBadge'; import { StatusBadge } from '@/components/requests/StatusBadge';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; 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 { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
@@ -39,12 +39,21 @@ export function AudiobookDetailsModal({
const { user } = useAuth(); const { user } = useAuth();
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest(); 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 [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('Request created successfully!');
const [requestError, setRequestError] = useState<string | null>(null); const [requestError, setRequestError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [asinCopied, setAsinCopied] = useState(false); const [asinCopied, setAsinCopied] = useState(false);
// Determine if ebook buttons should be shown
const canShowEbookButtons = isAvailable &&
ebookStatus?.ebookSourcesEnabled &&
!ebookStatus?.hasActiveEbookRequest;
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
@@ -68,6 +77,7 @@ export function AudiobookDetailsModal({
try { try {
await createRequest(audiobook); await createRequest(audiobook);
setToastMessage('Request created successfully!');
setShowToast(true); setShowToast(true);
setTimeout(() => { setTimeout(() => {
setShowToast(false); setShowToast(false);
@@ -103,6 +113,53 @@ export function AudiobookDetailsModal({
onRequestSuccess?.(); 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) => { const formatDuration = (minutes?: number) => {
if (!minutes) return null; if (!minutes) return null;
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@@ -419,6 +476,7 @@ export function AudiobookDetailsModal({
// Check if book is already available in library or completed status // Check if book is already available in library or completed status
if (isAvailable || requestStatus === 'completed') { if (isAvailable || requestStatus === 'completed') {
return ( return (
<>
<div className="flex-1"> <div className="flex-1">
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center"> <div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
<span className="text-base font-semibold text-green-700 dark:text-green-400"> <span className="text-base font-semibold text-green-700 dark:text-green-400">
@@ -426,6 +484,119 @@ export function AudiobookDetailsModal({
</span> </span>
</div> </div>
</div> </div>
{/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */}
{canShowEbookButtons && user && (
<>
{/* Grab Ebook Button */}
<button
onClick={handleFetchEbook}
disabled={isFetchingEbook}
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
}}
title="Grab Ebook"
aria-label="Grab Ebook"
>
{isFetchingEbook ? (
<div className="animate-spin w-6 h-6 border-2 border-current border-t-transparent rounded-full" style={{ color: '#f16f19' }} />
) : (
<svg
className="w-6 h-6"
style={{ color: '#f16f19' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
)}
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Grab Ebook
</span>
</button>
{/* Interactive Search Ebook Button */}
<button
onClick={handleInteractiveSearchEbook}
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
}}
title="Search Ebook Sources"
aria-label="Search Ebook Sources"
>
<svg
className="w-6 h-6"
style={{ color: '#f16f19' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Search Ebook Sources
</span>
</button>
</>
)}
{/* Show ebook request status if one exists */}
{ebookStatus?.hasActiveEbookRequest && (
<div
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
color: '#f16f19',
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval'
? 'Pending Approval'
: ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded'
? 'Available'
: 'In Progress'}
</span>
</div>
)}
</>
); );
} }
@@ -542,7 +713,7 @@ export function AudiobookDetailsModal({
{showToast && ( {showToast && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4"> <div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<p className="text-green-800 dark:text-green-200 text-center font-medium"> <p className="text-green-800 dark:text-green-200 text-center font-medium">
Request created successfully! {toastMessage}
</p> </p>
</div> </div>
)} )}
@@ -555,7 +726,7 @@ export function AudiobookDetailsModal({
return ( return (
<> <>
{createPortal(modalContent, document.body)} {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( {showInteractiveSearch && audiobook && createPortal(
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}> <div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
<div style={{ pointerEvents: 'auto' }}> <div style={{ pointerEvents: 'auto' }}>
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
</div>, </div>,
document.body document.body
)} )}
{/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */}
{showInteractiveSearchEbook && audiobook && createPortal(
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
<div style={{ pointerEvents: 'auto' }}>
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearchEbook}
onClose={handleInteractiveSearchEbookClose}
onSuccess={handleInteractiveSearchEbookSuccess}
asin={asin}
audiobook={{
title: audiobook.title,
author: audiobook.author,
}}
searchMode="ebook"
/>
</div>
</div>,
document.body
)}
</> </>
); );
} }
@@ -21,6 +21,8 @@ import {
useRequestWithTorrent, useRequestWithTorrent,
useInteractiveSearchEbook, useInteractiveSearchEbook,
useSelectEbook, useSelectEbook,
useInteractiveSearchEbookByAsin,
useSelectEbookByAsin,
} from '@/lib/hooks/useRequests'; } from '@/lib/hooks/useRequests';
import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { Audiobook } from '@/lib/hooks/useAudiobooks';
@@ -28,6 +30,7 @@ interface InteractiveTorrentSearchModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
requestId?: string; // Optional - only provided when called from existing request requestId?: string; // Optional - only provided when called from existing request
asin?: string; // Optional - ASIN for ebook mode when no request exists
audiobook: { audiobook: {
title: string; title: string;
author: string; author: string;
@@ -41,6 +44,7 @@ export function InteractiveTorrentSearchModal({
isOpen, isOpen,
onClose, onClose,
requestId, requestId,
asin,
audiobook, audiobook,
fullAudiobook, fullAudiobook,
onSuccess, onSuccess,
@@ -54,10 +58,14 @@ export function InteractiveTorrentSearchModal({
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); 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 { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook();
const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook(); 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 [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title); const [searchTitle, setSearchTitle] = useState(audiobook.title);
@@ -65,16 +73,18 @@ export function InteractiveTorrentSearchModal({
// Determine which mode we're in // Determine which mode we're in
const isEbookMode = searchMode === 'ebook'; const isEbookMode = searchMode === 'ebook';
const hasRequestId = !!requestId; const hasRequestId = !!requestId;
const hasAsin = !!asin;
const useAsinMode = isEbookMode && hasAsin && !hasRequestId;
// Loading/error state based on mode // Loading/error state based on mode
const isSearching = isEbookMode const isSearching = isEbookMode
? isSearchingEbooks ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
const isDownloading = isEbookMode const isDownloading = isEbookMode
? isSelectingEbook ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
const error = isEbookMode const error = isEbookMode
? (searchEbooksError || selectEbookError) ? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
: (hasRequestId : (hasRequestId
? (searchByRequestError || selectTorrentError) ? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError)); : (searchByAudiobookError || requestWithTorrentError));
@@ -100,20 +110,25 @@ export function InteractiveTorrentSearchModal({
let data; let data;
if (isEbookMode) { if (isEbookMode) {
// Ebook mode: search Anna's Archive + indexers // Ebook mode: search Anna's Archive + indexers
if (!requestId) { const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
console.error('Ebook search requires a requestId'); 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; return;
} }
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchEbooks(requestId, customTitle);
} else if (hasRequestId) { } else if (hasRequestId) {
// Existing audiobook flow: search by requestId with optional custom title // Existing audiobook flow: search by requestId with optional custom title
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle); data = await searchByRequestId(requestId, customTitle);
} else { } else {
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring // New audiobook flow: search by custom title + original author + optional ASIN for size scoring
const asin = fullAudiobook?.asin; const audiobookAsin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, asin); data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
} }
setResults(data || []); setResults(data || []);
} catch (err) { } catch (err) {
@@ -137,11 +152,16 @@ export function InteractiveTorrentSearchModal({
try { try {
if (isEbookMode) { if (isEbookMode) {
// Ebook flow: select ebook for existing audiobook request // Ebook flow
if (!requestId) { if (useAsinMode && asin) {
throw new Error('Request ID required for ebook selection'); // 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); await selectEbook(requestId, confirmTorrent);
} else {
throw new Error('Request ID or ASIN required for ebook selection');
}
} else if (hasRequestId) { } else if (hasRequestId) {
// Existing audiobook flow: select torrent for existing request // Existing audiobook flow: select torrent for existing request
await selectTorrent(requestId, confirmTorrent); await selectTorrent(requestId, confirmTorrent);
+159
View File
@@ -482,3 +482,162 @@ export function useSelectEbook() {
return { selectEbook, isLoading, error }; 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<EbookStatus>(
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<string | null>(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<string | null>(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<string | null>(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 };
}
+2 -1
View File
@@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches(
// Always enrich with request status (check ANY user's requests) // Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin); 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({ const audiobookRecords = await prisma.audiobook.findMany({
where: { where: {
audibleAsin: { in: asins }, audibleAsin: { in: asins },
@@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches(
requests: { requests: {
where: { where: {
deletedAt: null, // Only include active (non-deleted) requests deletedAt: null, // Only include active (non-deleted) requests
type: 'audiobook', // Only check audiobook requests, not ebook requests
}, },
select: { select: {
id: true, id: true,