Add first-class ebook request support and UI

Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior.
This commit is contained in:
kikootwo
2026-01-30 15:59:25 -05:00
parent 2cda6decbe
commit 590f089733
37 changed files with 2810 additions and 666 deletions
@@ -0,0 +1,216 @@
/**
* Component: Search Ebook Job Processor
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Searches Anna's Archive for ebook downloads.
* Part of the first-class ebook request flow.
*/
import { SearchEbookPayload, EbookSearchResult, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getConfigService } from '../services/config.service';
import { RMABLogger } from '../utils/logger';
// Import ebook scraper functions (we'll refactor these to be reusable)
import {
searchByAsin,
searchByTitle,
getSlowDownloadLinks,
} from '../services/ebook-scraper';
/**
* Process search ebook job
* Searches Anna's Archive for ebook matching the audiobook
*/
export async function processSearchEbook(payload: SearchEbookPayload): Promise<any> {
const { requestId, audiobook, preferredFormat: payloadFormat, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SearchEbook');
logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
try {
// Update request status to searching
await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
// Get ebook configuration
const configService = getConfigService();
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
if (flaresolverrUrl) {
logger.info(`Using FlareSolverr at ${flaresolverrUrl}`);
}
let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title';
// Step 1: Try ASIN search (exact match - best)
if (audiobook.asin) {
logger.info(`Searching by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl);
if (md5) {
logger.info(`Found via ASIN: ${md5}`);
searchMethod = 'asin';
} else {
logger.info(`No results for ASIN, falling back to title + author search...`);
}
}
// Step 2: Fallback to title + author search
if (!md5) {
logger.info(`Searching by title + author: "${audiobook.title}" by ${audiobook.author}...`);
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl);
if (md5) {
logger.info(`Found via title search: ${md5}`);
searchMethod = 'title';
}
}
if (!md5) {
// No results found - queue for re-search instead of failing
logger.warn(`No ebook found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'No ebook found on Anna\'s Archive. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No ebook found, queued for re-search',
requestId,
};
}
logger.info(`Found MD5: ${md5}`);
// Step 3: Get slow download links
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, logger, flaresolverrUrl);
if (slowLinks.length === 0) {
logger.warn(`No download links available for MD5: ${md5}`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'Found ebook but no download links available. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No download links available, queued for re-search',
requestId,
};
}
logger.info(`Found ${slowLinks.length} download link(s)`);
// Create ebook search result
// Note: For future multi-source ranking, this would be one of many results
const searchResult: EbookSearchResult = {
md5,
title: audiobook.title,
author: audiobook.author,
format: preferredFormat,
downloadUrls: slowLinks,
source: 'annas_archive',
score: searchMethod === 'asin' ? 100 : 80, // ASIN match = higher confidence
};
// TODO: Future enhancement - when indexer support is added for ebooks:
// 1. Search Prowlarr for ebook results (filtered to ebook categories)
// 2. Rank results using rankEbookResults() with inverted size scoring
// 3. Anna's Archive results should get priority bonus to come out on top
// For now, Anna's Archive is the only source and always wins.
logger.info(`==================== EBOOK SEARCH RESULT ====================`);
logger.info(`Title: "${audiobook.title}"`);
logger.info(`Author: "${audiobook.author}"`);
logger.info(`Match Method: ${searchMethod === 'asin' ? 'ASIN (exact)' : 'Title + Author (fuzzy)'}`);
logger.info(`Format: ${preferredFormat}`);
logger.info(`MD5: ${md5}`);
logger.info(`Download Links: ${slowLinks.length}`);
logger.info(`Score: ${searchResult.score}/100`);
logger.info(`==============================================================`);
// Create download history record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: 'Anna\'s Archive',
torrentName: `${audiobook.title} - ${audiobook.author}.${preferredFormat}`,
torrentSizeBytes: null, // Unknown until download starts
qualityScore: searchResult.score,
selected: true,
downloadClient: 'direct', // Direct HTTP download
downloadStatus: 'queued',
},
});
// Trigger direct download job with the best (only) result
const jobQueue = getJobQueueService();
// The first slow link will be tried; if it fails, the processor will try others
await jobQueue.addStartDirectDownloadJob(
requestId,
downloadHistory.id,
slowLinks[0], // Start with first link
`${audiobook.title} - ${audiobook.author}.${preferredFormat}`,
undefined // Size unknown
);
// Store all download URLs in download history for retry purposes
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: {
// Store additional URLs in torrentUrl field (JSON array)
torrentUrl: JSON.stringify(slowLinks),
},
});
return {
success: true,
message: `Found ebook via ${searchMethod === 'asin' ? 'ASIN' : 'title search'}, starting download`,
requestId,
searchResult: {
md5: searchResult.md5,
format: searchResult.format,
score: searchResult.score,
downloadLinksCount: slowLinks.length,
},
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error during ebook search',
updatedAt: new Date(),
},
});
throw error;
}
}