mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40: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:
@@ -44,12 +44,14 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
|
||||
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
|
||||
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
|
||||
// Before deleting torrent, we check if other active requests are using it
|
||||
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
|
||||
const completedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
|
||||
OR: [
|
||||
// Active requests that are fully available (scanned by Plex/ABS)
|
||||
{
|
||||
@@ -148,11 +150,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
|
||||
// CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash
|
||||
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
type: 'audiobook', // Only check audiobook requests
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Component: Direct Download Job Processors
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Handles direct HTTP downloads for ebooks from Anna's Archive.
|
||||
* Reports progress similar to qBittorrent/SABnzbd for unified UI.
|
||||
*/
|
||||
|
||||
import { StartDirectDownloadPayload, MonitorDirectDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { extractDownloadUrl, ExtractedDownload } from '../services/ebook-scraper';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 120000; // 2 minutes per download attempt
|
||||
const MAX_DOWNLOAD_ATTEMPTS = 5;
|
||||
const PROGRESS_UPDATE_INTERVAL_MS = 2000; // Update progress every 2 seconds
|
||||
|
||||
// In-memory tracking for active downloads
|
||||
interface ActiveDownload {
|
||||
id: string;
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
targetPath: string;
|
||||
bytesDownloaded: number;
|
||||
bytesTotal: number;
|
||||
startTime: number;
|
||||
lastUpdateTime: number;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const activeDownloads = new Map<string, ActiveDownload>();
|
||||
|
||||
/**
|
||||
* Generate unique download ID
|
||||
*/
|
||||
function generateDownloadId(): string {
|
||||
return `dl_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process start direct download job
|
||||
* Initiates the HTTP download and schedules monitoring
|
||||
*/
|
||||
export async function processStartDirectDownload(payload: StartDirectDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadUrl, targetFilename, expectedSize, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'DirectDownload');
|
||||
|
||||
logger.info(`Starting direct download for request ${requestId}`);
|
||||
|
||||
try {
|
||||
// Update request status to downloading
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
downloadAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update download history
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'downloading',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get download configuration
|
||||
const configService = getConfigService();
|
||||
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
// Get all download URLs from download history (stored as JSON in torrentUrl)
|
||||
const downloadHistory = await prisma.downloadHistory.findUnique({
|
||||
where: { id: downloadHistoryId },
|
||||
});
|
||||
|
||||
let downloadUrls: string[] = [];
|
||||
try {
|
||||
downloadUrls = downloadHistory?.torrentUrl ? JSON.parse(downloadHistory.torrentUrl) : [downloadUrl];
|
||||
} catch {
|
||||
downloadUrls = [downloadUrl];
|
||||
}
|
||||
|
||||
logger.info(`Have ${downloadUrls.length} download URL(s) to try`);
|
||||
|
||||
// Try each slow download URL until one succeeds
|
||||
let downloadResult: { success: boolean; filePath?: string; format?: string; error?: string } = {
|
||||
success: false,
|
||||
error: 'No download URLs available',
|
||||
};
|
||||
|
||||
const attemptsLimit = Math.min(downloadUrls.length, MAX_DOWNLOAD_ATTEMPTS);
|
||||
|
||||
for (let i = 0; i < attemptsLimit; i++) {
|
||||
const slowLink = downloadUrls[i];
|
||||
logger.info(`Attempting download link ${i + 1}/${attemptsLimit}...`);
|
||||
|
||||
try {
|
||||
// Extract actual download URL from slow download page
|
||||
const extracted = await extractDownloadUrl(
|
||||
slowLink,
|
||||
baseUrl,
|
||||
preferredFormat,
|
||||
logger,
|
||||
flaresolverrUrl
|
||||
);
|
||||
|
||||
if (!extracted) {
|
||||
logger.warn(`No download URL found on page ${i + 1}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Downloading from: ${new URL(extracted.url).host} (format: ${extracted.format})`);
|
||||
|
||||
// Build target path with actual format
|
||||
const sanitizedFilename = sanitizeFilename(`${targetFilename.replace(/\.[^.]+$/, '')}.${extracted.format}`);
|
||||
const targetPath = path.join(downloadsDir, sanitizedFilename);
|
||||
|
||||
// Create download tracking entry
|
||||
const downloadId = generateDownloadId();
|
||||
const downloadEntry: ActiveDownload = {
|
||||
id: downloadId,
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
targetPath,
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: expectedSize || 0,
|
||||
startTime: Date.now(),
|
||||
lastUpdateTime: Date.now(),
|
||||
completed: false,
|
||||
failed: false,
|
||||
};
|
||||
activeDownloads.set(downloadId, downloadEntry);
|
||||
|
||||
// Start download with progress tracking
|
||||
const success = await downloadFileWithProgress(
|
||||
extracted.url,
|
||||
targetPath,
|
||||
downloadEntry,
|
||||
logger
|
||||
);
|
||||
|
||||
if (success) {
|
||||
downloadResult = {
|
||||
success: true,
|
||||
filePath: targetPath,
|
||||
format: extracted.format,
|
||||
};
|
||||
|
||||
// Get final file size
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
downloadEntry.bytesTotal = stats.size;
|
||||
downloadEntry.bytesDownloaded = stats.size;
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
}
|
||||
|
||||
logger.info(`Download completed: ${sanitizedFilename}`);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.warn(`Download attempt ${i + 1} failed`);
|
||||
activeDownloads.delete(downloadId);
|
||||
} catch (error) {
|
||||
logger.warn(`Download link ${i + 1} error: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadResult.success) {
|
||||
// All attempts failed
|
||||
logger.error(`All ${attemptsLimit} download attempts failed`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: downloadResult.error || 'All download attempts failed',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: downloadResult.error || 'All download attempts failed',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Download failed',
|
||||
requestId,
|
||||
error: downloadResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Download succeeded - update records and trigger organize
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'completed',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get audiobook ID for organize job
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error('Request not found after download');
|
||||
}
|
||||
|
||||
// Trigger organize files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
request.audiobookId,
|
||||
downloadResult.filePath!
|
||||
);
|
||||
|
||||
logger.info(`Download complete, triggered organize job for ${downloadResult.filePath}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Download completed, organizing files',
|
||||
requestId,
|
||||
filePath: downloadResult.filePath,
|
||||
format: downloadResult.format,
|
||||
};
|
||||
} 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 download',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file with progress tracking
|
||||
*/
|
||||
async function downloadFileWithProgress(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
tracking: ActiveDownload,
|
||||
logger: RMABLogger
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Ensure target directory exists
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
// Start download with axios streaming
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
responseType: 'stream',
|
||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook/1.0 (Audiobook Automation)',
|
||||
},
|
||||
});
|
||||
|
||||
// Get content length if available
|
||||
const contentLength = parseInt(response.headers['content-length'] || '0', 10);
|
||||
if (contentLength > 0) {
|
||||
tracking.bytesTotal = contentLength;
|
||||
}
|
||||
|
||||
// Create write stream
|
||||
const writer = createWriteStream(targetPath);
|
||||
|
||||
// Track progress
|
||||
let bytesDownloaded = 0;
|
||||
let lastLogTime = Date.now();
|
||||
let lastDbUpdateTime = Date.now();
|
||||
|
||||
response.data.on('data', (chunk: Buffer) => {
|
||||
bytesDownloaded += chunk.length;
|
||||
tracking.bytesDownloaded = bytesDownloaded;
|
||||
tracking.lastUpdateTime = Date.now();
|
||||
|
||||
// Log and update database every 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime >= 2000) {
|
||||
const percent = tracking.bytesTotal > 0
|
||||
? Math.round((bytesDownloaded / tracking.bytesTotal) * 100)
|
||||
: 0;
|
||||
const speedMBps = bytesDownloaded / ((now - tracking.startTime) / 1000) / (1024 * 1024);
|
||||
logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`);
|
||||
lastLogTime = now;
|
||||
|
||||
// Update database with progress (non-blocking)
|
||||
if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastDbUpdateTime = now;
|
||||
|
||||
// Non-blocking update - fire and forget
|
||||
prisma.request.update({
|
||||
where: { id: tracking.requestId },
|
||||
data: {
|
||||
progress: Math.min(percent, 99), // Cap at 99% until fully complete
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}).catch(() => {}); // Ignore errors during progress update
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe to file
|
||||
response.data.pipe(writer);
|
||||
|
||||
// Wait for completion
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
tracking.completed = true;
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
tracking.failed = true;
|
||||
tracking.error = error.message;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
tracking.failed = true;
|
||||
tracking.error = error.message;
|
||||
writer.close();
|
||||
// Clean up partial file
|
||||
fs.unlink(targetPath).catch(() => {});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
tracking.failed = true;
|
||||
tracking.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Clean up partial file
|
||||
try {
|
||||
await fs.unlink(targetPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process monitor direct download job
|
||||
* Checks download progress and updates database
|
||||
* Note: For direct downloads, most tracking happens in processStartDirectDownload
|
||||
* This is kept for potential future use with async downloads
|
||||
*/
|
||||
export async function processMonitorDirectDownload(payload: MonitorDirectDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadId, targetPath, expectedSize, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDirectDownload');
|
||||
|
||||
// Check if download is tracked
|
||||
const download = activeDownloads.get(downloadId);
|
||||
|
||||
if (!download) {
|
||||
// Download not in memory - check file existence
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
logger.info(`Download file exists: ${targetPath} (${stats.size} bytes)`);
|
||||
|
||||
// If file exists and is complete, assume success
|
||||
if (expectedSize && stats.size >= expectedSize) {
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
message: 'Download already completed',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
logger.warn(`Download ${downloadId} not found in tracking`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Download not found',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
// Update database with progress
|
||||
const progress = download.bytesTotal > 0
|
||||
? Math.min(99, Math.round((download.bytesDownloaded / download.bytesTotal) * 100))
|
||||
: 0;
|
||||
|
||||
const elapsed = Date.now() - download.startTime;
|
||||
const speed = elapsed > 0 ? download.bytesDownloaded / (elapsed / 1000) : 0;
|
||||
const eta = speed > 0 && download.bytesTotal > download.bytesDownloaded
|
||||
? Math.round((download.bytesTotal - download.bytesDownloaded) / speed)
|
||||
: 0;
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
progress,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (download.completed) {
|
||||
logger.info(`Download ${downloadId} completed`);
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
requestId,
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
bytesTotal: download.bytesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
if (download.failed) {
|
||||
logger.error(`Download ${downloadId} failed: ${download.error}`);
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
requestId,
|
||||
error: download.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Still in progress - schedule another monitor
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorDirectDownloadJob(
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
downloadId,
|
||||
targetPath,
|
||||
expectedSize,
|
||||
PROGRESS_UPDATE_INTERVAL_MS / 1000
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completed: false,
|
||||
requestId,
|
||||
progress,
|
||||
speed,
|
||||
eta,
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
bytesTotal: download.bytesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for filesystem
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars
|
||||
.replace(/\s+/g, ' ') // Collapse spaces
|
||||
.trim()
|
||||
.substring(0, 200); // Limit length
|
||||
}
|
||||
@@ -57,9 +57,11 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
return { success: true, message: 'No RSS results', matched: 0 };
|
||||
}
|
||||
|
||||
// Get all active requests awaiting search (missing audiobooks)
|
||||
// Get all active audiobook requests awaiting search (missing audiobooks)
|
||||
// Note: RSS feeds are for torrents, so only audiobook requests are matched
|
||||
const missingRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (RSS feeds are for torrents)
|
||||
status: 'awaiting_search',
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import { generateFilesHash } from '../utils/files-hash';
|
||||
/**
|
||||
* Process organize files job
|
||||
* Moves completed downloads to media library in proper directory structure
|
||||
* Handles both audiobook and ebook request types with appropriate branching
|
||||
*/
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
@@ -24,6 +25,27 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
logger.info(`Download path: ${downloadPath}`);
|
||||
|
||||
try {
|
||||
// Fetch request to determine type
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error(`Request ${requestId} not found`);
|
||||
}
|
||||
|
||||
const requestType = request.type || 'audiobook'; // Default to audiobook for backward compatibility
|
||||
logger.info(`Request type: ${requestType}`);
|
||||
|
||||
// Branch based on request type
|
||||
if (requestType === 'ebook') {
|
||||
return await processEbookOrganization(payload, request, logger);
|
||||
}
|
||||
|
||||
// Continue with audiobook organization flow
|
||||
// Update request status to processing
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -149,6 +171,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
errors: result.errors,
|
||||
});
|
||||
|
||||
// Create ebook request if ebook downloads enabled (for audiobook requests only)
|
||||
// This replaces the old inline ebook sidecar download
|
||||
await createEbookRequestIfEnabled(requestId, audiobook, request.userId, result.targetPath, logger);
|
||||
|
||||
// Trigger filesystem scan if enabled (Plex or Audiobookshelf)
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
@@ -433,3 +459,215 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC ORGANIZATION
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Process ebook organization (simplified flow compared to audiobooks)
|
||||
* - No metadata tagging
|
||||
* - No cover art download
|
||||
* - No files hash generation
|
||||
* - Sends "available" notification at downloaded state (terminal for ebooks)
|
||||
*/
|
||||
async function processEbookOrganization(
|
||||
payload: OrganizeFilesPayload,
|
||||
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
|
||||
logger: RMABLogger
|
||||
): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
|
||||
logger.info(`Processing ebook organization for request ${requestId}`);
|
||||
|
||||
// Update request status to processing
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get book details (works for both audiobooks and ebooks)
|
||||
const book = await prisma.audiobook.findUnique({
|
||||
where: { id: audiobookId },
|
||||
});
|
||||
|
||||
if (!book) {
|
||||
throw new Error(`Book ${audiobookId} not found`);
|
||||
}
|
||||
|
||||
logger.info(`Organizing ebook: ${book.title} by ${book.author}`);
|
||||
|
||||
// Get file organizer and template
|
||||
const organizer = await getFileOrganizer();
|
||||
const templateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
});
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
|
||||
// Organize ebook files (organizer will detect ebook type and skip audio-specific processing)
|
||||
const result = await organizer.organizeEbook(
|
||||
downloadPath,
|
||||
{
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
asin: book.audibleAsin || undefined,
|
||||
year: book.year || undefined,
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Ebook organization failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
logger.info(`Successfully moved ebook to ${result.targetPath}`);
|
||||
|
||||
// Update book record with file path
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: {
|
||||
filePath: result.targetPath,
|
||||
fileFormat: result.format || 'epub',
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update request to downloaded (terminal state for ebooks)
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloaded',
|
||||
progress: 100,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
|
||||
|
||||
// Send "available" notification for ebooks at downloaded state
|
||||
// (since ebooks don't transition to 'available' via Plex matching)
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
requestId,
|
||||
book.title,
|
||||
book.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger filesystem scan if enabled (same as audiobooks)
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
const configKey = backendMode === 'audiobookshelf'
|
||||
? 'audiobookshelf.trigger_scan_after_import'
|
||||
: 'plex.trigger_scan_after_import';
|
||||
const scanEnabled = await configService.get(configKey);
|
||||
|
||||
logger.debug(`Ebook library scan check: backendMode=${backendMode}, configKey=${configKey}, scanEnabled=${scanEnabled}`);
|
||||
|
||||
if (scanEnabled === 'true') {
|
||||
try {
|
||||
const libraryService = await getLibraryService();
|
||||
const libraryId = backendMode === 'audiobookshelf'
|
||||
? await configService.get('audiobookshelf.library_id')
|
||||
: await configService.get('plex_audiobook_library_id');
|
||||
|
||||
if (libraryId) {
|
||||
await libraryService.triggerLibraryScan(libraryId);
|
||||
logger.info(`Triggered ${backendMode} filesystem scan for library ${libraryId}`);
|
||||
} else {
|
||||
logger.warn(`Library ID not configured for ${backendMode}, skipping scan`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ebook organized successfully',
|
||||
requestId,
|
||||
audiobookId,
|
||||
targetPath: result.targetPath,
|
||||
format: result.format,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ebook request if ebook downloads are enabled
|
||||
* Called after audiobook organization completes
|
||||
*/
|
||||
async function createEbookRequestIfEnabled(
|
||||
parentRequestId: string,
|
||||
audiobook: { id: string; title: string; author: string; audibleAsin: string | null },
|
||||
userId: string,
|
||||
targetPath: string,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if ebook downloads are enabled
|
||||
const configService = getConfigService();
|
||||
const ebookEnabled = await configService.get('ebook_sidecar_enabled');
|
||||
|
||||
if (ebookEnabled !== 'true') {
|
||||
logger.info('Ebook downloads disabled, skipping ebook request creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if an ebook request already exists for this parent
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEbookRequest) {
|
||||
logger.info(`Ebook request already exists for parent ${parentRequestId}: ${existingEbookRequest.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Creating ebook request for "${audiobook.title}" (parent: ${parentRequestId})`);
|
||||
|
||||
// Create new ebook request (auto-approved since parent was approved)
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'pending', // Will trigger search_ebook job
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created ebook request ${ebookRequest.id}`);
|
||||
|
||||
// Trigger ebook search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(ebookRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
|
||||
} catch (error) {
|
||||
// Don't fail the main audiobook organization if ebook request creation fails
|
||||
logger.error(`Failed to create ebook request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,9 +249,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
}
|
||||
}
|
||||
|
||||
// Check for all non-terminal requests to match
|
||||
// Check for all non-terminal audiobook requests to match
|
||||
// Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -37,9 +37,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
};
|
||||
|
||||
// Find all active requests in awaiting_import status
|
||||
// Find all active audiobook requests in awaiting_import status
|
||||
// Note: Ebook requests use the same organize_files processor but with type branching
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -21,9 +21,11 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
logger.info('Starting retry job for requests awaiting search...');
|
||||
|
||||
try {
|
||||
// Find all active requests in awaiting_search status
|
||||
// Find all active audiobook requests in awaiting_search status
|
||||
// Note: Ebook requests have separate search mechanism (search_ebook job)
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks use different search)
|
||||
status: 'awaiting_search',
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -433,10 +433,12 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
logger.info(`No orphaned audiobooks found`);
|
||||
}
|
||||
|
||||
// 6. Match all non-terminal requests against library
|
||||
// 6. Match all non-terminal audiobook requests against library
|
||||
// Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status
|
||||
logger.info(`Checking for matchable requests...`);
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user