mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user