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:
kikootwo
2026-01-30 15:59:25 -05:00
parent 2cda6decbe
commit 590f089733
37 changed files with 2810 additions and 666 deletions
+31 -8
View File
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get active downloads with related data
// Get active downloads with related data (both audiobook and ebook)
const activeDownloads = await prisma.request.findMany({
where: {
status: 'downloading',
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
select: {
id: true,
status: true,
type: true, // 'audiobook' or 'ebook'
progress: true,
updatedAt: true,
audiobook: {
@@ -54,6 +55,8 @@ export async function GET(request: NextRequest) {
torrentName: true,
torrentHash: true,
nzbId: true,
downloadClient: true, // qbittorrent, sabnzbd, or direct
torrentSizeBytes: true,
startedAt: true,
createdAt: true,
},
@@ -75,19 +78,38 @@ export async function GET(request: NextRequest) {
let speed = 0;
let eta: number | null = null;
const downloadHistory = download.downloadHistory[0];
const downloadClient = downloadHistory?.downloadClient;
try {
if (clientType === 'qbittorrent') {
if (downloadClient === 'direct') {
// Direct HTTP download (ebooks) - estimate speed from progress and time elapsed
const startedAt = downloadHistory?.startedAt || downloadHistory?.createdAt;
const totalSize = downloadHistory?.torrentSizeBytes ? Number(downloadHistory.torrentSizeBytes) : 0;
if (startedAt && download.progress > 0 && totalSize > 0) {
const elapsedMs = Date.now() - new Date(startedAt).getTime();
const elapsedSeconds = elapsedMs / 1000;
const bytesDownloaded = (download.progress / 100) * totalSize;
if (elapsedSeconds > 0) {
speed = Math.round(bytesDownloaded / elapsedSeconds);
const remainingBytes = totalSize - bytesDownloaded;
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
}
}
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
// Get torrent hash from download history
const torrentHash = download.downloadHistory[0]?.torrentHash;
const torrentHash = downloadHistory?.torrentHash;
if (torrentHash) {
const qbService = await getQBittorrentService();
const torrentInfo = await qbService.getTorrent(torrentHash);
speed = torrentInfo.dlspeed;
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
}
} else if (clientType === 'sabnzbd') {
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
// Get NZB ID from download history
const nzbId = download.downloadHistory[0]?.nzbId;
const nzbId = downloadHistory?.nzbId;
if (nzbId) {
const sabnzbdService = await getSABnzbdService();
const nzbInfo = await sabnzbdService.getNZB(nzbId);
@@ -107,13 +129,14 @@ export async function GET(request: NextRequest) {
title: download.audiobook.title,
author: download.audiobook.author,
status: download.status,
type: download.type, // 'audiobook' or 'ebook'
progress: download.progress,
speed,
eta,
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
torrentName: downloadHistory?.torrentName || null,
downloadStatus: downloadHistory?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
startedAt: downloadHistory?.startedAt || downloadHistory?.createdAt || download.updatedAt,
};
})
);
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
type: request.type, // 'audiobook' or 'ebook'
user: request.user.plexUsername,
createdAt: request.createdAt,
completedAt: request.completedAt,
@@ -61,13 +61,14 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
// First check: Is there an existing request in 'downloaded' or 'available' status?
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
// This catches the gap where files are organized but Plex hasn't scanned yet
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: {
audibleAsin: audiobook.asin,
},
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
@@ -181,11 +182,12 @@ export async function POST(request: NextRequest) {
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
}
// Check if user already has an active (non-deleted) request for this audiobook
// Check if user already has an active (non-deleted) audiobook request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
@@ -263,6 +265,7 @@ export async function POST(request: NextRequest) {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'awaiting_approval',
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
selectedTorrent: torrent as any, // Store the selected torrent for later
},
@@ -304,6 +307,7 @@ export async function POST(request: NextRequest) {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'downloading',
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
},
include: {
+3
View File
@@ -136,6 +136,8 @@ async function handler(req: AuthenticatedRequest) {
where: {
userId,
audiobookId: audiobook.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
@@ -187,6 +189,7 @@ async function handler(req: AuthenticatedRequest) {
userId,
audiobookId: audiobook.id,
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
priority: 0,
},
});
+81 -94
View File
@@ -2,16 +2,13 @@
* Component: Fetch E-book API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Triggers e-book download for a completed request
* Creates an ebook request for a completed audiobook request
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { downloadEbook } from '@/lib/services/ebook-scraper';
import { buildAudiobookPath } from '@/lib/utils/file-organizer';
import fs from 'fs/promises';
import path from 'path';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.FetchEbook');
@@ -23,7 +20,7 @@ export async function POST(
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const { id: parentRequestId } = await params;
// Check if e-book sidecar is enabled
const ebookEnabledConfig = await prisma.configuration.findUnique({
@@ -37,118 +34,108 @@ export async function POST(
);
}
// Get the request with audiobook data
const requestRecord = await prisma.request.findUnique({
where: { id },
// Get the parent request with audiobook data
const parentRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: {
audiobook: true,
},
});
if (!requestRecord) {
if (!parentRequest) {
return NextResponse.json(
{ error: 'Request not found' },
{ status: 404 }
);
}
// Check if request is in completed state
if (!['downloaded', 'available'].includes(requestRecord.status)) {
// Check if parent request is in completed state
if (!['downloaded', 'available'].includes(parentRequest.status)) {
return NextResponse.json(
{ error: `Cannot fetch e-book for request in ${requestRecord.status} status` },
{ error: `Cannot fetch e-book for request in ${parentRequest.status} status` },
{ status: 400 }
);
}
const audiobook = requestRecord.audiobook;
// Get configuration
const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }),
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 mediaDir = mediaDirConfig?.value || '/media/audiobooks';
const template = templateConfig?.value || '{author}/{title} {asin}';
const preferredFormat = formatConfig?.value || 'epub';
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
// Fetch year from audible cache if ASIN is available
let year: number | undefined;
if (audiobook.audibleAsin) {
const audibleCache = await prisma.audibleCache.findUnique({
where: { asin: audiobook.audibleAsin },
select: { releaseDate: true },
});
if (audibleCache?.releaseDate) {
year = new Date(audibleCache.releaseDate).getFullYear();
}
}
// Build target path using centralized function
const targetPath = buildAudiobookPath(
mediaDir,
template,
{
author: audiobook.author,
title: audiobook.title,
narrator: audiobook.narrator || undefined,
asin: audiobook.audibleAsin || undefined,
year,
}
);
logger.debug('Fetch e-book request', {
requestId: id,
title: audiobook.title,
author: audiobook.author,
targetPath,
format: preferredFormat,
baseUrl,
flaresolverr: flaresolverrUrl || 'none'
// Check if an ebook request already exists for this parent
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
// Check if target directory exists
try {
await fs.access(targetPath);
} catch {
logger.debug(`Target directory not found: ${targetPath}`);
return NextResponse.json(
{ error: 'Audiobook directory not found. Was the audiobook properly organized?' },
{ status: 400 }
);
}
if (existingEbookRequest) {
// Check status - if failed/pending, we can retry
if (['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
// Reset and retry
await prisma.request.update({
where: { id: existingEbookRequest.id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
});
// Download e-book
const result = await downloadEbook(
audiobook.audibleAsin || '',
audiobook.title,
audiobook.author,
targetPath,
preferredFormat,
baseUrl,
undefined, // No logger in API context
flaresolverrUrl
);
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchEbookJob(existingEbookRequest.id, {
id: parentRequest.audiobook.id,
title: parentRequest.audiobook.title,
author: parentRequest.audiobook.author,
asin: parentRequest.audiobook.audibleAsin || undefined,
});
if (result.success) {
logger.info(`E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'} for "${audiobook.title}"`);
return NextResponse.json({
success: true,
message: `E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'}`,
format: result.format,
});
} else {
logger.warn(`E-book download failed for "${audiobook.title}"`, { error: result.error });
logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${parentRequest.audiobook.title}"`);
return NextResponse.json({
success: true,
message: 'E-book search retried',
requestId: existingEbookRequest.id,
});
}
// Already exists and not in a retryable state
return NextResponse.json({
success: false,
message: result.error || 'E-book download failed',
message: `E-book request already exists (status: ${existingEbookRequest.status})`,
requestId: existingEbookRequest.id,
});
}
// Create new ebook request
const ebookRequest = await prisma.request.create({
data: {
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
status: 'pending',
progress: 0,
},
});
logger.info(`Created ebook request ${ebookRequest.id} for "${parentRequest.audiobook.title}"`);
// Trigger ebook search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchEbookJob(ebookRequest.id, {
id: parentRequest.audiobook.id,
title: parentRequest.audiobook.title,
author: parentRequest.audiobook.author,
asin: parentRequest.audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
return NextResponse.json({
success: true,
message: 'E-book request created and search started',
requestId: ebookRequest.id,
});
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
+10 -2
View File
@@ -45,13 +45,14 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// First check: Is there an existing request in 'downloaded' or 'available' status?
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
// This catches the gap where files are organized but Plex hasn't scanned yet
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: {
audibleAsin: audiobook.asin,
},
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
@@ -165,11 +166,12 @@ export async function POST(request: NextRequest) {
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
}
// Check if user already has an active (non-deleted) request for this audiobook
// Check if user already has an active (non-deleted) audiobook request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
@@ -257,6 +259,7 @@ export async function POST(request: NextRequest) {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
},
include: {
@@ -353,6 +356,7 @@ export async function GET(request: NextRequest) {
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const myOnly = searchParams.get('myOnly') === 'true';
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
const isAdmin = req.user.role === 'admin';
// Build query
@@ -362,6 +366,10 @@ export async function GET(request: NextRequest) {
if (status) {
where.status = status;
}
// Filter by type if specified (otherwise returns all types)
if (type && ['audiobook', 'ebook'].includes(type)) {
where.type = type;
}
// Only show active (non-deleted) requests
where.deletedAt = null;