mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-07-14 16:51: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,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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user