mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
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:
+107
-50
@@ -19,7 +19,6 @@ import {
|
||||
checkDiskSpace,
|
||||
} from './chapter-merger';
|
||||
import { prisma } from '../db';
|
||||
import { downloadEbook } from '../services/ebook-scraper';
|
||||
import { substituteTemplate, type TemplateVariables } from './path-template.util';
|
||||
|
||||
export interface AudiobookMetadata {
|
||||
@@ -42,6 +41,13 @@ export interface OrganizationResult {
|
||||
coverArtFile?: string;
|
||||
}
|
||||
|
||||
export interface EbookOrganizationResult {
|
||||
success: boolean;
|
||||
targetPath: string;
|
||||
errors: string[];
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
issues: string[];
|
||||
@@ -399,55 +405,10 @@ export class FileOrganizer {
|
||||
}
|
||||
}
|
||||
|
||||
// E-book sidecar: Download accompanying e-book if enabled
|
||||
try {
|
||||
const ebookConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'ebook_sidecar_enabled' },
|
||||
});
|
||||
|
||||
const ebookEnabled = ebookConfig?.value === 'true';
|
||||
|
||||
if (ebookEnabled) {
|
||||
await logger?.info(`E-book sidecar enabled, searching for e-book...`);
|
||||
|
||||
// Get configuration
|
||||
const [formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
|
||||
]);
|
||||
|
||||
const preferredFormat = formatConfig?.value || 'epub';
|
||||
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
|
||||
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
|
||||
|
||||
// Download e-book (will try ASIN first, then fall back to title+author)
|
||||
const ebookResult = await downloadEbook(
|
||||
audiobook.asin || '', // ASIN (optional - will fallback to title+author if empty)
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
targetPath, // Same directory as audiobook
|
||||
preferredFormat,
|
||||
baseUrl,
|
||||
logger ?? undefined,
|
||||
flaresolverrUrl
|
||||
);
|
||||
|
||||
if (ebookResult.success && ebookResult.filePath) {
|
||||
await logger?.info(`E-book downloaded: ${path.basename(ebookResult.filePath)}`);
|
||||
result.filesMovedCount++;
|
||||
} else {
|
||||
await logger?.warn(`E-book download failed: ${ebookResult.error}`);
|
||||
result.errors.push(`E-book sidecar: ${ebookResult.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.warn(
|
||||
`E-book sidecar error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
result.errors.push('E-book sidecar failed');
|
||||
// Don't throw - audiobook organization continues
|
||||
}
|
||||
// NOTE: E-book downloads are now handled via first-class ebook requests
|
||||
// The createEbookRequestIfEnabled() function in organize-files.processor.ts
|
||||
// creates a separate ebook request that goes through the full job queue flow.
|
||||
// This replaces the old inline ebook sidecar download that happened here.
|
||||
|
||||
result.targetPath = targetPath;
|
||||
result.success = true;
|
||||
@@ -680,6 +641,102 @@ export class FileOrganizer {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize ebook file into proper directory structure
|
||||
* Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging
|
||||
*/
|
||||
async organizeEbook(
|
||||
downloadPath: string,
|
||||
metadata: { title: string; author: string; asin?: string; year?: number },
|
||||
template: string,
|
||||
loggerConfig?: LoggerConfig
|
||||
): Promise<EbookOrganizationResult> {
|
||||
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
|
||||
|
||||
const result: EbookOrganizationResult = {
|
||||
success: false,
|
||||
targetPath: '',
|
||||
errors: [],
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
result.format = ext;
|
||||
await logger?.info(`Detected ebook format: ${ext}`);
|
||||
|
||||
// Build target directory using same template as audiobooks
|
||||
const targetDir = this.buildTargetPath(
|
||||
this.mediaDir,
|
||||
template,
|
||||
metadata.author,
|
||||
metadata.title,
|
||||
undefined, // narrator
|
||||
metadata.asin,
|
||||
metadata.year
|
||||
);
|
||||
|
||||
await logger?.info(`Target directory: ${targetDir}`);
|
||||
|
||||
// Create target directory
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Build target filename (sanitize source filename)
|
||||
const sourceFilename = path.basename(downloadPath);
|
||||
const targetFilename = this.sanitizePath(sourceFilename);
|
||||
const targetPath = path.join(targetDir, targetFilename);
|
||||
|
||||
// Check if target already exists
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
await logger?.info(`Ebook already exists at target, skipping copy: ${targetFilename}`);
|
||||
result.success = true;
|
||||
result.targetPath = targetDir;
|
||||
return result;
|
||||
} catch {
|
||||
// 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);
|
||||
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
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.targetPath = targetDir;
|
||||
|
||||
await logger?.info(`Ebook organization complete: ${targetFilename}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await logger?.error(`Ebook organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -624,6 +624,161 @@ export class RankingAlgorithm {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK RANKING (simplified algorithm for ebook search results)
|
||||
// =========================================================================
|
||||
|
||||
export interface EbookResult {
|
||||
md5: string;
|
||||
title: string;
|
||||
author: string;
|
||||
format: string; // epub, pdf, mobi, etc.
|
||||
fileSize?: number; // in bytes
|
||||
downloadUrls: string[];
|
||||
source: 'annas_archive' | 'prowlarr'; // Source of the result
|
||||
indexerId?: number; // Prowlarr indexer ID (if applicable)
|
||||
}
|
||||
|
||||
export interface EbookRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
preferredFormat: string; // User's preferred format (epub, pdf, etc.)
|
||||
}
|
||||
|
||||
export interface RankedEbook extends EbookResult {
|
||||
score: number; // Total score (0-100)
|
||||
rank: number;
|
||||
breakdown: {
|
||||
formatScore: number; // 0-40 points
|
||||
sizeScore: number; // 0-30 points (inverted - smaller is better)
|
||||
sourceScore: number; // 0-30 points (Anna's Archive priority)
|
||||
notes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank ebook search results
|
||||
* Scoring priorities (inverted from audiobooks):
|
||||
* - Format match: 40 points (matching preferred format)
|
||||
* - Size: 30 points (smaller files = better, inverted from audiobooks)
|
||||
* - Source: 30 points (Anna's Archive priority for reliability)
|
||||
*/
|
||||
export function rankEbooks(
|
||||
results: EbookResult[],
|
||||
request: EbookRequest
|
||||
): RankedEbook[] {
|
||||
const preferredFormat = request.preferredFormat.toLowerCase();
|
||||
|
||||
const ranked = results.map((result): RankedEbook => {
|
||||
const notes: string[] = [];
|
||||
|
||||
// ========== FORMAT SCORING (0-40 points) ==========
|
||||
// Exact format match gets full points
|
||||
// Similar formats get partial credit
|
||||
let formatScore = 0;
|
||||
const resultFormat = result.format.toLowerCase();
|
||||
|
||||
if (resultFormat === preferredFormat) {
|
||||
formatScore = 40;
|
||||
notes.push(`✓ Preferred format (${result.format.toUpperCase()})`);
|
||||
} else {
|
||||
// Partial credit for compatible formats
|
||||
const ebookFormatGroups = [
|
||||
['epub', 'kepub'], // EPUB family
|
||||
['mobi', 'azw', 'azw3'], // Kindle family
|
||||
['pdf'], // PDF standalone
|
||||
['fb2', 'fb2.zip'], // FB2 family
|
||||
['cbz', 'cbr'], // Comic formats
|
||||
];
|
||||
|
||||
const preferredGroup = ebookFormatGroups.find(g => g.includes(preferredFormat));
|
||||
const resultGroup = ebookFormatGroups.find(g => g.includes(resultFormat));
|
||||
|
||||
if (preferredGroup && resultGroup && preferredGroup === resultGroup) {
|
||||
formatScore = 30; // Same family
|
||||
notes.push(`Similar format (${result.format.toUpperCase()})`);
|
||||
} else if (resultFormat === 'epub') {
|
||||
formatScore = 25; // EPUB is universally convertible
|
||||
notes.push(`Convertible format (${result.format.toUpperCase()})`);
|
||||
} else if (resultFormat === 'pdf') {
|
||||
formatScore = 15; // PDF is common but less flexible
|
||||
notes.push(`PDF format (less flexible)`);
|
||||
} else {
|
||||
formatScore = 10; // Other formats
|
||||
notes.push(`Different format (${result.format.toUpperCase()})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SIZE SCORING (0-30 points, inverted) ==========
|
||||
// For ebooks, smaller files are generally better (cleaner, no bloat)
|
||||
// Typical ebook sizes: 0.5-5 MB (good), 5-20 MB (has images), 20+ MB (may have issues)
|
||||
let sizeScore = 0;
|
||||
|
||||
if (result.fileSize !== undefined && result.fileSize > 0) {
|
||||
const sizeMB = result.fileSize / (1024 * 1024);
|
||||
|
||||
if (sizeMB <= 2) {
|
||||
sizeScore = 30; // Ideal size
|
||||
notes.push('✓ Optimal file size');
|
||||
} else if (sizeMB <= 5) {
|
||||
sizeScore = 25; // Good size
|
||||
notes.push('Good file size');
|
||||
} else if (sizeMB <= 15) {
|
||||
sizeScore = 20; // Has images, acceptable
|
||||
notes.push('Larger file (may have images)');
|
||||
} else if (sizeMB <= 50) {
|
||||
sizeScore = 10; // Large, possibly bloated
|
||||
notes.push('⚠️ Large file size');
|
||||
} else {
|
||||
sizeScore = 5; // Very large, suspicious
|
||||
notes.push('⚠️ Very large file (may include extras)');
|
||||
}
|
||||
} else {
|
||||
// No size info - give middle score
|
||||
sizeScore = 15;
|
||||
notes.push('File size unknown');
|
||||
}
|
||||
|
||||
// ========== SOURCE SCORING (0-30 points) ==========
|
||||
// Anna's Archive is the primary reliable source
|
||||
// Future: Prowlarr indexers will get configurable priority
|
||||
let sourceScore = 0;
|
||||
|
||||
if (result.source === 'annas_archive') {
|
||||
sourceScore = 30; // Full points for Anna's Archive
|
||||
notes.push('✓ Anna\'s Archive (reliable)');
|
||||
} else if (result.source === 'prowlarr') {
|
||||
// Future: Use indexer priority from config
|
||||
sourceScore = 15; // Base score for Prowlarr results
|
||||
notes.push('Prowlarr indexer');
|
||||
}
|
||||
|
||||
const totalScore = formatScore + sizeScore + sourceScore;
|
||||
|
||||
return {
|
||||
...result,
|
||||
score: totalScore,
|
||||
rank: 0, // Will be assigned after sorting
|
||||
breakdown: {
|
||||
formatScore,
|
||||
sizeScore,
|
||||
sourceScore,
|
||||
notes,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Assign ranks
|
||||
ranked.forEach((r, index) => {
|
||||
r.rank = index + 1;
|
||||
});
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let ranker: RankingAlgorithm | null = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user