mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
137e2b5607
Persist and apply customSearchTerms across ebook workflows and searches. Updated admin search-terms PATCH to enqueue addSearchEbookJob for ebook requests. Included customSearchTerms when creating ebook request records in audiobooks/[asin]/fetch-ebook, audiobooks/[asin]/select-ebook and requests/[id]/fetch-ebook. Reworked requests/[id]/select-ebook to handle being passed either an audiobook or ebook request (resolve parent audiobook, reuse existing ebook request if present) and to propagate parent.customSearchTerms when creating new ebook requests. Modified search-ebook.processor to read customSearchTerms from the request record, use it as the effective search title (with logging), and pass the modified audiobook title into Anna's Archive and indexer searches so custom terms are honored.
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
/**
|
|
* 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,
|
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
|
},
|
|
});
|
|
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,
|
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
|
},
|
|
});
|
|
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}`);
|
|
}
|