mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add ebook-sidecar APIs and UI integration
Introduce ebook-sidecar support: add new API routes for ebook workflows (ebook-status, fetch-ebook, interactive-search-ebook, select-ebook) that handle searching, selection, request creation, approval, and download routing (Anna's Archive direct downloads vs indexer downloads). Update admin approval flow to understand request.type (audiobook | ebook), handle pre-selected ebook torrents (including special handling for Anna's Archive with direct download jobs and download history), and enqueue ebook-specific search/download jobs. Frontend changes: show request type badge in admin pending approvals and augment AudiobookDetailsModal to query ebook status, start fetch/interactive ebook searches, and surface toast notifications. Also include new request lifecycle handling (retryable/active statuses, approval logic, creating audiobook records for Plex-imported books) and ranking/normalization logic for interactive ebook search results. Other: various plumbing to integrate config checks, job queue calls, and download history storage for ebook downloads.
This commit is contained in:
+18
-3
@@ -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">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
<div className="flex items-center gap-2">
|
||||||
{request.audiobook.title}
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
</h3>
|
{request.audiobook.title}
|
||||||
|
</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,26 +76,67 @@ 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
|
||||||
await jobQueue.addDownloadJob(
|
if (isEbookRequest && selectedTorrent.source === 'annas_archive') {
|
||||||
existingRequest.id,
|
// Create download history record for Anna's Archive
|
||||||
{
|
const downloadHistory = await prisma.downloadHistory.create({
|
||||||
id: existingRequest.audiobook.id,
|
data: {
|
||||||
title: existingRequest.audiobook.title,
|
requestId: existingRequest.id,
|
||||||
author: existingRequest.audiobook.author,
|
indexerName: "Anna's Archive",
|
||||||
},
|
torrentName: `${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`,
|
||||||
existingRequest.selectedTorrent as any
|
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
|
// 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
|
||||||
await jobQueue.addSearchJob(updatedRequest.id, {
|
if (isEbookRequest) {
|
||||||
id: updatedRequest.audiobook.id,
|
await jobQueue.addSearchEbookJob(updatedRequest.id, {
|
||||||
title: updatedRequest.audiobook.title,
|
id: updatedRequest.audiobook.id,
|
||||||
author: updatedRequest.audiobook.author,
|
title: updatedRequest.audiobook.title,
|
||||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
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
|
// 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,13 +476,127 @@ 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="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="flex-1">
|
||||||
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
<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">
|
||||||
Available in Your Library
|
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
||||||
</span>
|
Available in Your Library
|
||||||
|
</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);
|
||||||
|
} else {
|
||||||
|
throw new Error('Request ID or ASIN required for ebook selection');
|
||||||
}
|
}
|
||||||
await selectEbook(requestId, confirmTorrent);
|
|
||||||
} 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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user