mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 21:30:11 +00:00
Add interactive ebook search & selection
Introduce interactive ebook support: adds two API endpoints to search (interactive-search-ebook) and create/select ebook requests (select-ebook), plus server-side handlers to route Anna's Archive (direct) and indexer (torrent/NZB) downloads. Frontend: extend RequestActionsDropdown and InteractiveTorrentSearchModal to support an "ebook" search mode and selection flow, and add hooks (useInteractiveSearchEbook / useSelectEbook). Settings: add ebook_auto_grab_enabled with UI toggle and enforce disabling when no ebook sources are enabled; settings GET/PUT updated to persist the flag (default = true to preserve behavior). Documentation updated (scheduler, ebook-sidecar, settings pages) and ranking algorithm docs/tests extended to cover ebook-related normalization and matching cases. Includes logging and ranking integration for indexer results and normalization for Anna's Archive handling.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Component: Select Ebook 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
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, 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 { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.SelectEbook');
|
||||
|
||||
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; // 'torrent' or 'usenet' - determines download client
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id: parentRequestId } = await params;
|
||||
const body = await request.json();
|
||||
const selectedEbook = body.ebook as SelectedEbook;
|
||||
|
||||
if (!selectedEbook) {
|
||||
return NextResponse.json({ error: 'No ebook selected' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!selectedEbook.source) {
|
||||
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot select ebook for request in ${parentRequest.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing ebook request
|
||||
let ebookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${ebookRequest.status})`,
|
||||
existingRequestId: ebookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Create or update ebook request
|
||||
if (ebookRequest) {
|
||||
// Reset existing failed/pending request
|
||||
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 {
|
||||
// Create new ebook request
|
||||
ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: parentRequest.userId,
|
||||
audiobookId: parentRequest.audiobookId,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
}
|
||||
|
||||
const audiobook = parentRequest.audiobook;
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Route to appropriate download based on source
|
||||
if (selectedEbook.source === 'annas_archive') {
|
||||
// Anna's Archive: Direct HTTP download
|
||||
await handleAnnasArchiveDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
} else {
|
||||
// Indexer: Torrent/NZB download
|
||||
await handleIndexerDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`,
|
||||
requestId: ebookRequest.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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, // Unknown until download starts
|
||||
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 // Size unknown
|
||||
);
|
||||
|
||||
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}`);
|
||||
|
||||
// Convert to RankedTorrent shape expected by download job
|
||||
// Note: format is omitted as ebook formats (epub, pdf) differ from audiobook formats (M4B, M4A, MP3)
|
||||
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, // Pass through protocol for torrent vs usenet routing
|
||||
};
|
||||
|
||||
// Use the download job (same as audiobooks)
|
||||
await jobQueue.addDownloadJob(requestId, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}, torrentForJob as any); // Cast to any since ebook torrents don't have audiobook format field
|
||||
|
||||
logger.info(`Queued download job for request ${requestId}`);
|
||||
}
|
||||
Reference in New Issue
Block a user