Add multi-source ebook search & processing

Refactor ebook flow to support multiple sources (Anna's Archive direct downloads + Prowlarr indexer search) and unify handling with existing audiobook processors. Key changes:
- search-ebook.processor: rewritten to try Anna's Archive first then fall back to indexer search, add Prowlarr grouping, ranking (rankEbookTorrents), and handlers to route results to direct-download or download-torrent flows.
- organize-files.processor: enriches audiobook/ebook metadata from AudibleCache (year, narrator), treats indexer downloads specially (seed retention), adds optional NZB cleanup/archive logic, and improves retryable error detection.
- file-organizer: organizeEbook now accepts additional metadata and an isIndexerDownload flag and supports directories vs single-file paths.
- API/UI: include request.type in admin requests API and remove the “coming soon” notice from Ebook settings tab.
- fetch-ebook route: removed blocking error for indexer-only mode so the flow can proceed when indexer search is enabled.
- Documentation: update TOC, ebook-sidecar, settings-pages, and ranking-algorithm docs to describe indexer search, unified ebook ranking, configuration, and flows.
These changes enable indexer-based ebook discovery, ranking, and downloads while preserving existing Anna's Archive behavior and reusing audiobook download processors where possible.
This commit is contained in:
kikootwo
2026-02-02 12:27:54 -05:00
parent 433123fcc3
commit 9dd09ec836
11 changed files with 1142 additions and 238 deletions
+87 -23
View File
@@ -645,12 +645,14 @@ export class FileOrganizer {
/**
* Organize ebook file into proper directory structure
* Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging
* Supports both direct file paths (Anna's Archive) and directories (indexer downloads)
*/
async organizeEbook(
downloadPath: string,
metadata: { title: string; author: string; asin?: string; year?: number },
metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string },
template: string,
loggerConfig?: LoggerConfig
loggerConfig?: LoggerConfig,
isIndexerDownload: boolean = false
): Promise<EbookOrganizationResult> {
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -663,19 +665,21 @@ export class FileOrganizer {
try {
await logger?.info(`Organizing ebook: ${downloadPath}`);
// Get file info
const stats = await fs.stat(downloadPath);
if (!stats.isFile()) {
throw new Error('Ebook download path must be a file');
const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr'];
// Find ebook file (handle both file and directory cases)
const { ebookFile, baseSourcePath, isFile } = await this.findEbookFile(downloadPath, ebookFormats);
if (!ebookFile) {
throw new Error(`No ebook files found in download (looking for: ${ebookFormats.join(', ')})`);
}
// Build full path to source file
const sourceFilePath = isFile ? downloadPath : path.join(baseSourcePath, ebookFile);
await logger?.info(`Found ebook file: ${ebookFile}`);
// Detect format from extension
const ext = path.extname(downloadPath).toLowerCase().slice(1);
const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr'];
if (!ebookFormats.includes(ext)) {
throw new Error(`Unsupported ebook format: ${ext}`);
}
const ext = path.extname(ebookFile).toLowerCase().slice(1);
result.format = ext;
await logger?.info(`Detected ebook format: ${ext}`);
@@ -685,9 +689,11 @@ export class FileOrganizer {
template,
metadata.author,
metadata.title,
undefined, // narrator
metadata.narrator,
metadata.asin,
metadata.year
metadata.year,
metadata.series,
metadata.seriesPart
);
await logger?.info(`Target directory: ${targetDir}`);
@@ -696,7 +702,7 @@ export class FileOrganizer {
await fs.mkdir(targetDir, { recursive: true });
// Build target filename (sanitize source filename)
const sourceFilename = path.basename(downloadPath);
const sourceFilename = path.basename(ebookFile);
const targetFilename = this.sanitizePath(sourceFilename);
const targetPath = path.join(targetDir, targetFilename);
@@ -711,18 +717,22 @@ export class FileOrganizer {
// File doesn't exist, continue with copy
}
// Copy ebook file (don't delete original in case of direct download retry)
await fs.copyFile(downloadPath, targetPath);
// Copy ebook file (do NOT delete original - may need for seeding or retry)
await fs.copyFile(sourceFilePath, targetPath);
await fs.chmod(targetPath, 0o644);
await logger?.info(`Copied ebook: ${targetFilename}`);
// Clean up source file (for direct HTTP downloads, we don't need to keep the original)
try {
await fs.unlink(downloadPath);
await logger?.info(`Cleaned up source file: ${sourceFilename}`);
} catch {
// Ignore cleanup errors
// Clean up source file ONLY for direct HTTP downloads (not indexer downloads which need to seed)
if (!isIndexerDownload && isFile) {
try {
await fs.unlink(sourceFilePath);
await logger?.info(`Cleaned up source file: ${sourceFilename}`);
} catch {
// Ignore cleanup errors
}
} else if (isIndexerDownload) {
await logger?.info(`Keeping source file for seeding: ${sourceFilename}`);
}
result.success = true;
@@ -737,6 +747,60 @@ export class FileOrganizer {
return result;
}
}
/**
* Find ebook file in download path (handles both single file and directory)
*/
private async findEbookFile(
downloadPath: string,
ebookFormats: string[]
): Promise<{ ebookFile: string | null; baseSourcePath: string; isFile: boolean }> {
let ebookFile: string | null = null;
let isFile = false;
try {
const stats = await fs.stat(downloadPath);
if (stats.isFile()) {
// Handle single file case
isFile = true;
const ext = path.extname(downloadPath).toLowerCase().slice(1);
if (ebookFormats.includes(ext)) {
ebookFile = path.basename(downloadPath);
}
} else {
// Handle directory case - find ebook files inside
const files = await this.walkDirectory(downloadPath);
// Filter to ebook files and sort by preference (epub > pdf > others)
const ebookFiles = files.filter(file => {
const ext = path.extname(file).toLowerCase().slice(1);
return ebookFormats.includes(ext);
});
if (ebookFiles.length > 0) {
// Sort by format preference
ebookFiles.sort((a, b) => {
const extA = path.extname(a).toLowerCase().slice(1);
const extB = path.extname(b).toLowerCase().slice(1);
const priorityOrder = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
return priorityOrder.indexOf(extA) - priorityOrder.indexOf(extB);
});
ebookFile = ebookFiles[0];
}
}
} catch {
// Path doesn't exist or inaccessible
}
return {
ebookFile,
baseSourcePath: downloadPath,
isFile,
};
}
}
/**