Add e-book sidecar integration and improve request handling

Introduces optional e-book sidecar downloads from Anna's Archive, including admin UI, settings API, FlareSolverr integration, and documentation. Enhances request creation logic to prevent duplicate downloads by checking for 'downloaded' and 'available' statuses, updates UI to reflect processing state, and adds SABnzbd support to download and cleanup flows. Also updates ranking algorithm documentation and improves cache invalidation for recent requests.
This commit is contained in:
kikootwo
2026-01-07 17:19:42 -05:00
parent 24ea53bd2f
commit 95c25ff73a
26 changed files with 1968 additions and 116 deletions
+31 -35
View File
@@ -7,6 +7,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -48,6 +50,7 @@ export async function GET(request: NextRequest) {
downloadStatus: true,
torrentName: true,
torrentHash: true,
nzbId: true,
startedAt: true,
createdAt: true,
},
@@ -59,48 +62,41 @@ export async function GET(request: NextRequest) {
take: 20,
});
// Get qBittorrent service
let qbService;
try {
qbService = await getQBittorrentService();
} catch (error) {
console.error('[Admin] Failed to initialize qBittorrent service:', error);
// Return downloads without speed/eta if qBittorrent is unavailable
const formatted = activeDownloads.map((download) => ({
requestId: download.id,
title: download.audiobook.title,
author: download.audiobook.author,
status: download.status,
progress: download.progress,
speed: 0,
eta: null,
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
}));
return NextResponse.json({ downloads: formatted });
}
// Get configured download client type
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Format response with speed and ETA from qBittorrent
// Format response with speed and ETA from download client
const formatted = await Promise.all(
activeDownloads.map(async (download) => {
let speed = 0;
let eta: number | null = null;
// Get torrent hash from download history
const torrentHash = download.downloadHistory[0]?.torrentHash;
// Fetch torrent info from qBittorrent if we have a hash
if (torrentHash) {
try {
const torrentInfo = await qbService.getTorrent(torrentHash);
speed = torrentInfo.dlspeed;
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
} catch (error) {
// Torrent not found or other error - use defaults
console.error(`[Admin] Failed to get torrent info for ${torrentHash}:`, error);
try {
if (clientType === 'qbittorrent') {
// Get torrent hash from download history
const torrentHash = download.downloadHistory[0]?.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') {
// Get NZB ID from download history
const nzbId = download.downloadHistory[0]?.nzbId;
if (nzbId) {
const sabnzbdService = await getSABnzbdService();
const nzbInfo = await sabnzbdService.getNZB(nzbId);
if (nzbInfo) {
speed = nzbInfo.downloadSpeed;
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
}
}
}
} catch (error) {
// Download client unavailable or download not found - use defaults
console.error(`[Admin] Failed to get download info:`, error);
}
return {
+84
View File
@@ -0,0 +1,84 @@
/**
* Component: E-book Sidecar Settings API
* Documentation: documentation/integrations/ebook-sidecar.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Parse request body
const { enabled, format, baseUrl, flaresolverrUrl } = await request.json();
// Validate format
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
if (format && !validFormats.includes(format)) {
return NextResponse.json(
{ error: `Invalid format. Must be one of: ${validFormats.join(', ')}` },
{ status: 400 }
);
}
// Validate baseUrl (basic check)
if (baseUrl && !baseUrl.startsWith('http')) {
return NextResponse.json(
{ error: 'Base URL must start with http:// or https://' },
{ status: 400 }
);
}
// Validate flaresolverrUrl if provided
if (flaresolverrUrl && !flaresolverrUrl.startsWith('http')) {
return NextResponse.json(
{ error: 'FlareSolverr URL must start with http:// or https://' },
{ status: 400 }
);
}
// Save configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const configs = [
{
key: 'ebook_sidecar_enabled',
value: enabled ? 'true' : 'false',
category: 'ebook',
description: 'Enable e-book sidecar downloads from Annas Archive',
},
{
key: 'ebook_sidecar_preferred_format',
value: format || 'epub',
category: 'ebook',
description: 'Preferred e-book format',
},
{
key: 'ebook_sidecar_base_url',
value: baseUrl || 'https://annas-archive.li',
category: 'ebook',
description: 'Base URL for Annas Archive',
},
{
key: 'ebook_sidecar_flaresolverr_url',
value: flaresolverrUrl || '',
category: 'ebook',
description: 'FlareSolverr URL for bypassing Cloudflare protection',
},
];
await configService.setMany(configs);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to save e-book settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,45 @@
/**
* Component: FlareSolverr Connection Test API
* Documentation: documentation/integrations/ebook-sidecar.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { testFlareSolverrConnection } from '@/lib/services/ebook-scraper';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url } = await request.json();
if (!url) {
return NextResponse.json(
{ error: 'FlareSolverr URL is required' },
{ status: 400 }
);
}
if (!url.startsWith('http')) {
return NextResponse.json(
{ error: 'URL must start with http:// or https://' },
{ status: 400 }
);
}
const result = await testFlareSolverrConnection(url);
return NextResponse.json(result);
} catch (error) {
console.error('FlareSolverr test failed:', error);
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
+6
View File
@@ -82,6 +82,12 @@ export async function GET(request: NextRequest) {
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
},
ebook: {
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
allowRegistrations: configMap.get('allow_registrations') === 'true',
@@ -24,6 +24,8 @@ export async function POST(request: NextRequest) {
localPath,
} = await request.json();
console.log('[TestDownloadClient] Received request:', { type, url, hasUsername: !!username, hasPassword: !!password });
if (!type || !url) {
return NextResponse.json(
{ success: false, error: 'Type and URL are required' },
@@ -59,6 +61,7 @@ export async function POST(request: NextRequest) {
let version: string | undefined;
if (type === 'qbittorrent') {
console.log('[TestDownloadClient] Testing qBittorrent connection');
if (!username || !actualPassword) {
return NextResponse.json(
{ success: false, error: 'Username and password are required for qBittorrent' },
@@ -74,6 +77,7 @@ export async function POST(request: NextRequest) {
disableSSLVerify || false
);
} else if (type === 'sabnzbd') {
console.log('[TestDownloadClient] Testing SABnzbd connection');
if (!actualPassword) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
@@ -57,7 +57,40 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
// Check if audiobook is already available in Plex library
// First check: Is there an existing 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,
},
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
include: {
user: { select: { plexUsername: true } },
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
const isOwnRequest = existingActiveRequest.userId === req.user.id;
return NextResponse.json(
{
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
message: status === 'available'
? 'This audiobook is already available in your Plex library'
: 'This audiobook is being processed and will be available soon',
requestStatus: status,
isOwnRequest,
requestedBy: existingActiveRequest.user?.plexUsername,
},
{ status: 409 }
);
}
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
+33 -5
View File
@@ -194,11 +194,39 @@ export async function PATCH(
const downloadHistory = requestWithData.downloadHistory[0];
// Get download path from qBittorrent
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId!);
const downloadPath = `${torrent.save_path}/${torrent.name}`;
// Get download path from the appropriate download client
let downloadPath: string;
if (downloadHistory.torrentHash) {
// qBittorrent - get path from torrent info
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadPath = `${torrent.save_path}/${torrent.name}`;
} else if (downloadHistory.nzbId) {
// SABnzbd - get path from NZB info
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (!nzbInfo || !nzbInfo.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Download path not available from SABnzbd',
},
{ status: 400 }
);
}
downloadPath = nzbInfo.downloadPath;
} else {
return NextResponse.json(
{
error: 'ValidationError',
message: 'No download client ID found in history',
},
{ status: 400 }
);
}
await jobQueue.addOrganizeJob(
id,
+34 -1
View File
@@ -41,7 +41,40 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// Check if audiobook is already available in Plex library
// First check: Is there an existing 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,
},
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
include: {
user: { select: { plexUsername: true } },
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
const isOwnRequest = existingActiveRequest.userId === req.user.id;
return NextResponse.json(
{
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
message: status === 'available'
? 'This audiobook is already available in your Plex library'
: 'This audiobook is being processed and will be available soon',
requestStatus: status,
isOwnRequest,
requestedBy: existingActiveRequest.user?.plexUsername,
},
{ status: 409 }
);
}
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,