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,
};
}
}
/**
+304
View File
@@ -42,6 +42,18 @@ export interface RankTorrentsOptions {
requireAuthor?: boolean; // Enforce author presence check (default: true)
}
export interface EbookTorrentRequest {
title: string;
author: string;
preferredFormat: string; // User's preferred format (epub, pdf, etc.)
}
export interface RankEbookTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true)
}
export interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
@@ -67,6 +79,24 @@ export interface RankedTorrent extends TorrentResult {
breakdown: ScoreBreakdown;
}
export interface EbookScoreBreakdown {
formatScore: number; // 0-10 points (match preferred = 10, else 0)
sizeScore: number; // 0-15 points (inverted - smaller is better)
seederScore: number; // 0-15 points (same as audiobooks)
matchScore: number; // 0-60 points (same as audiobooks)
totalScore: number;
notes: string[];
}
export interface RankedEbookTorrent extends TorrentResult {
score: number; // Base score (0-100)
bonusModifiers: BonusModifier[];
bonusPoints: number; // Sum of all bonus points
finalScore: number; // score + bonusPoints
rank: number;
breakdown: EbookScoreBreakdown;
}
export class RankingAlgorithm {
/**
* Rank all torrents and return sorted by finalScore (best first)
@@ -622,6 +652,257 @@ export class RankingAlgorithm {
return notes;
}
// =========================================================================
// EBOOK TORRENT RANKING (for indexer results)
// Reuses scoreMatch() and scoreSeeders() from audiobook ranking
// Uses ebook-specific format and size scoring
// =========================================================================
/**
* Rank ebook torrents from indexers
* Reuses title/author matching and seeder scoring from audiobook ranking
* Uses ebook-specific format scoring (10 pts for match, 0 otherwise)
* Uses inverted size scoring (smaller = better, > 20MB filtered)
*
* @param torrents - Array of torrent results from Prowlarr
* @param ebook - Ebook request details (title, author, preferredFormat)
* @param options - Optional configuration for ranking behavior
*/
rankEbookTorrents(
torrents: TorrentResult[],
ebook: EbookTorrentRequest,
options: RankEbookTorrentsOptions = {}
): RankedEbookTorrent[] {
const {
indexerPriorities,
flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode
} = options;
// Filter out files > 20 MB (too large for ebooks)
const filteredTorrents = torrents.filter((torrent) => {
const sizeMB = torrent.size / (1024 * 1024);
return sizeMB <= 20;
});
const ranked = filteredTorrents.map((torrent) => {
// Calculate base scores (0-100)
// Reuse scoreMatch and scoreSeeders from audiobook ranking
const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat);
const sizeScore = this.scoreEbookSize(torrent);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, {
title: ebook.title,
author: ebook.author,
}, requireAuthor);
const baseScore = formatScore + sizeScore + seederScore + matchScore;
// Calculate bonus modifiers (same as audiobooks)
const bonusModifiers: BonusModifier[] = [];
// Indexer priority bonus (default: 10/25 = 40%)
if (torrent.indexerId !== undefined) {
const priority = indexerPriorities?.get(torrent.indexerId) ?? 10;
const modifier = priority / 25; // Convert 1-25 to 0.04-1.0 (4%-100%)
const points = baseScore * modifier;
bonusModifiers.push({
type: 'indexer_priority',
value: modifier,
points: points,
reason: `Indexer priority ${priority}/25 (${Math.round(modifier * 100)}%)`,
});
}
// Flag bonuses/penalties (same as audiobooks)
if (torrent.flags && torrent.flags.length > 0 && flagConfigs && flagConfigs.length > 0) {
torrent.flags.forEach(torrentFlag => {
const matchingConfig = flagConfigs.find(cfg =>
cfg.name.trim().toLowerCase() === torrentFlag.trim().toLowerCase()
);
if (matchingConfig) {
const modifier = matchingConfig.modifier / 100;
const points = baseScore * modifier;
bonusModifiers.push({
type: 'indexer_flag',
value: modifier,
points: points,
reason: `Flag "${torrentFlag}" (${matchingConfig.modifier > 0 ? '+' : ''}${matchingConfig.modifier}%)`,
});
}
});
}
// Sum all bonus points
const bonusPoints = bonusModifiers.reduce((sum, mod) => sum + mod.points, 0);
// Calculate final score
const finalScore = baseScore + bonusPoints;
return {
...torrent,
score: baseScore,
bonusModifiers,
bonusPoints,
finalScore,
rank: 0, // Will be assigned after sorting
breakdown: {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: this.generateEbookNotes(torrent, {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: [],
}, ebook.preferredFormat),
},
};
});
// Sort by finalScore descending (best first), then by publishDate descending (newest first)
ranked.sort((a, b) => {
if (b.finalScore !== a.finalScore) {
return b.finalScore - a.finalScore;
}
return b.publishDate.getTime() - a.publishDate.getTime();
});
// Assign ranks
ranked.forEach((r, index) => {
r.rank = index + 1;
});
return ranked;
}
/**
* Score ebook format (10 points max)
* Full points for matching preferred format, 0 otherwise
*/
private scoreEbookFormat(torrent: TorrentResult, preferredFormat: string): number {
const detectedFormat = this.detectEbookFormat(torrent);
const preferred = preferredFormat.toLowerCase();
// Exact match = full points, otherwise 0
if (detectedFormat === preferred) {
return 10;
}
return 0;
}
/**
* Score ebook file size (15 points max, inverted - smaller is better)
* < 5 MB = 15 pts (full)
* 5-15 MB = 10 pts
* 15-20 MB = 5 pts
* > 20 MB = filtered out (not scored)
*/
private scoreEbookSize(torrent: TorrentResult): number {
const sizeMB = torrent.size / (1024 * 1024);
if (sizeMB < 5) {
return 15; // Optimal size for ebooks
} else if (sizeMB <= 15) {
return 10; // Acceptable, may have images
} else if (sizeMB <= 20) {
return 5; // Large but within limit
}
// > 20 MB should have been filtered, but return 0 as safety
return 0;
}
/**
* Detect ebook format from torrent title
*/
private detectEbookFormat(torrent: TorrentResult): string {
const title = torrent.title.toLowerCase();
// Check for common ebook format extensions/keywords
if (title.includes('.epub') || title.includes(' epub')) return 'epub';
if (title.includes('.pdf') || title.includes(' pdf')) return 'pdf';
if (title.includes('.mobi') || title.includes(' mobi')) return 'mobi';
if (title.includes('.azw3') || title.includes(' azw3')) return 'azw3';
if (title.includes('.azw') || title.includes(' azw')) return 'azw';
if (title.includes('.fb2') || title.includes(' fb2')) return 'fb2';
if (title.includes('.cbz') || title.includes(' cbz')) return 'cbz';
if (title.includes('.cbr') || title.includes(' cbr')) return 'cbr';
// Default to unknown
return 'unknown';
}
/**
* Generate human-readable notes for ebook scoring
*/
private generateEbookNotes(
torrent: TorrentResult,
breakdown: EbookScoreBreakdown,
preferredFormat: string
): string[] {
const notes: string[] = [];
// Format notes
const detectedFormat = this.detectEbookFormat(torrent);
if (breakdown.formatScore === 10) {
notes.push(`✓ Preferred format (${detectedFormat.toUpperCase()})`);
} else if (detectedFormat !== 'unknown') {
notes.push(`Different format (${detectedFormat.toUpperCase()}, wanted ${preferredFormat.toUpperCase()})`);
} else {
notes.push('⚠️ Unknown format');
}
// Size notes
const sizeMB = torrent.size / (1024 * 1024);
if (sizeMB < 5) {
notes.push('✓ Optimal file size');
} else if (sizeMB <= 15) {
notes.push('Good file size (may have images)');
} else if (sizeMB <= 20) {
notes.push('⚠️ Large file size');
}
// Seeder notes (same logic as audiobooks)
if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
if (torrent.seeders === 0) {
notes.push('⚠️ No seeders available');
} else if (torrent.seeders < 5) {
notes.push(`Low seeders (${torrent.seeders})`);
} else if (torrent.seeders >= 50) {
notes.push(`Excellent availability (${torrent.seeders} seeders)`);
}
}
// Match notes (same thresholds as audiobooks)
if (breakdown.matchScore < 24) {
notes.push('⚠️ Poor title/author match');
} else if (breakdown.matchScore < 42) {
notes.push('⚠️ Weak title/author match');
} else if (breakdown.matchScore >= 54) {
notes.push('✓ Excellent title/author match');
}
// Overall quality assessment
if (breakdown.totalScore >= 75) {
notes.push('✓ Excellent choice');
} else if (breakdown.totalScore >= 55) {
notes.push('✓ Good choice');
} else if (breakdown.totalScore < 35) {
notes.push('⚠️ Consider reviewing this choice');
}
return notes;
}
}
// =========================================================================
@@ -844,3 +1125,26 @@ export function rankTorrents(
qualityScore: Math.round(r.score),
}));
}
/**
* Helper function to rank ebook torrents using the singleton instance
*
* @param torrents - Array of torrent results from Prowlarr
* @param ebook - Ebook request details (title, author, preferredFormat)
* @param options - Optional ranking configuration
* @returns Ranked ebook torrents with quality scores
*/
export function rankEbookTorrents(
torrents: TorrentResult[],
ebook: EbookTorrentRequest,
options?: RankEbookTorrentsOptions
): (RankedEbookTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankEbookTorrents(torrents, ebook, options || {});
// Add qualityScore field for UI compatibility (rounded score)
return ranked.map((r) => ({
...r,
qualityScore: Math.round(r.score),
}));
}