mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-06 14:20:10 +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:
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user