Merge branch 'ebook-piecewise'

This commit is contained in:
kikootwo
2026-02-03 10:47:06 -05:00
68 changed files with 7451 additions and 30862 deletions
@@ -16,6 +16,7 @@ interface ActiveDownload {
eta: number | null;
user: string;
startedAt: Date;
type?: 'audiobook' | 'ebook';
}
interface ActiveDownloadsTableProps {
@@ -77,7 +78,7 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Audiobook
Request
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
@@ -104,8 +105,21 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{download.title}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{download.title}
</span>
{download.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{download.author}
@@ -19,6 +19,7 @@ interface RecentRequest {
title: string;
author: string;
status: string;
type?: 'audiobook' | 'ebook';
userId: string;
user: string;
createdAt: Date;
@@ -557,7 +558,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
onClick={() => handleSort('title')}
>
<div className="flex items-center gap-2">
Audiobook
Request
<SortIcon field="title" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
@@ -610,8 +611,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</span>
{request.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
@@ -644,6 +658,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
title: request.title,
author: request.author,
status: request.status,
type: request.type,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
@@ -18,6 +18,7 @@ export interface RequestActionsDropdownProps {
title: string;
author: string;
status: string;
type?: 'audiobook' | 'ebook';
torrentUrl?: string | null;
};
onDelete: (requestId: string, title: string) => void;
@@ -39,17 +40,49 @@ export function RequestActionsDropdown({
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
// Determine request type
const isEbook = request.type === 'ebook';
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete
// Only show "View Source" if we have a valid indexer page URL (not a magnet link)
const canViewSource = !!request.torrentUrl &&
!request.torrentUrl.startsWith('magnet:') &&
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
// For audiobooks and indexer-sourced ebooks, show indexer page URL (not magnet links)
let viewSourceUrl: string | null = null;
if (isEbook && request.torrentUrl) {
// torrentUrl for ebooks can be:
// 1. JSON array of slow download URLs (Anna's Archive) - extract MD5
// 2. Plain URL string (indexer source) - use directly
try {
const urls = JSON.parse(request.torrentUrl);
if (Array.isArray(urls) && urls.length > 0) {
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
if (md5Match) {
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`;
}
}
} catch {
// Not JSON - it's a plain URL from indexer source
// Use it directly if it's not a magnet link
if (!request.torrentUrl.startsWith('magnet:')) {
viewSourceUrl = request.torrentUrl;
}
}
} else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) {
viewSourceUrl = request.torrentUrl;
}
const canViewSource = !!viewSourceUrl &&
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
// Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests
const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
// Close dropdown when clicking outside
useEffect(() => {
@@ -82,6 +115,11 @@ export function RequestActionsDropdown({
setShowInteractiveSearch(true);
};
const handleInteractiveSearchEbook = () => {
setIsOpen(false);
setShowInteractiveSearchEbook(true);
};
const handleCancel = async () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -166,9 +204,9 @@ export function RequestActionsDropdown({
)}
{/* View Source */}
{canViewSource && (
{canViewSource && viewSourceUrl && (
<a
href={request.torrentUrl!}
href={viewSourceUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsOpen(false)}
@@ -192,7 +230,7 @@ export function RequestActionsDropdown({
</a>
)}
{/* Fetch E-book */}
{/* Grab E-book (automatic) */}
{canFetchEbook && (
<button
onClick={handleFetchEbook}
@@ -212,7 +250,31 @@ export function RequestActionsDropdown({
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Try to fetch Ebook
Grab Ebook
</button>
)}
{/* Interactive Search E-book */}
{canFetchEbook && (
<button
onClick={handleInteractiveSearchEbook}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
Interactive Search Ebook
</button>
)}
@@ -300,7 +362,7 @@ export function RequestActionsDropdown({
{/* Dropdown menu (rendered via portal) */}
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
{/* Interactive Search Modal */}
{/* Interactive Search Modal (Audiobook) */}
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearch}
onClose={() => setShowInteractiveSearch(false)}
@@ -310,6 +372,18 @@ export function RequestActionsDropdown({
author: request.author,
}}
/>
{/* Interactive Search Modal (Ebook) */}
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearchEbook}
onClose={() => setShowInteractiveSearchEbook(false)}
requestId={request.requestId}
audiobook={{
title: request.title,
author: request.author,
}}
searchMode="ebook"
/>
</>
);
}
+19 -4
View File
@@ -18,6 +18,7 @@ import { useState } from 'react';
interface PendingApprovalRequest {
id: string;
createdAt: string;
type: 'audiobook' | 'ebook';
audiobook: {
title: string;
author: string;
@@ -146,9 +147,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{/* Book Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{request.audiobook.title}
</h3>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{request.audiobook.title}
</h3>
{request.type === 'ebook' && (
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0"
style={{
backgroundColor: 'rgba(241, 111, 25, 0.15)',
color: '#f16f19',
border: '1px solid rgba(241, 111, 25, 0.3)',
}}
>
Ebook
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{request.audiobook.author}
</p>
@@ -489,7 +504,7 @@ function AdminDashboardContent() {
Request Management
</h2>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
</div>
+12 -4
View File
@@ -103,12 +103,18 @@ export interface PathsSettings {
/**
* E-book sidecar configuration
* Supports two sources: Anna's Archive (direct HTTP) and Indexer Search (Prowlarr)
*/
export interface EbookSettings {
enabled: boolean;
preferredFormat: string;
// Source toggles
annasArchiveEnabled: boolean;
indexerSearchEnabled: boolean;
// Anna's Archive specific settings
baseUrl: string;
flaresolverrUrl: string;
// General settings (shared across sources)
preferredFormat: string;
autoGrabEnabled: boolean;
}
/**
@@ -143,7 +149,8 @@ export interface IndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories?: number[];
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
ebookCategories?: number[]; // Category IDs for ebook searches (default: [7020])
supportsRss?: boolean;
}
@@ -158,7 +165,8 @@ export interface SavedIndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
ebookCategories: number[]; // Category IDs for ebook searches (default: [7020])
}
/**
+2 -1
View File
@@ -106,7 +106,8 @@ export default function AdminSettings() {
protocol: idx.protocol,
priority: idx.priority,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
audiobookCategories: idx.audiobookCategories || [3030],
ebookCategories: idx.ebookCategories || [7020],
};
// Add protocol-specific fields
+218 -134
View File
@@ -1,6 +1,11 @@
/**
* Component: E-book Settings Tab
* Documentation: documentation/settings-pages.md
*
* Three-section layout:
* 1. Anna's Archive - Direct HTTP downloads from Anna's Archive
* 2. Indexer Search - Search via Prowlarr indexers (future feature)
* 3. General Settings - Shared settings like preferred format
*/
'use client';
@@ -27,167 +32,246 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
updateEbook,
testFlaresolverrConnection,
saveSettings,
isAnySourceEnabled,
} = useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved });
return (
<div className="space-y-6 max-w-2xl">
{/* Header */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
E-book Sidecar
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Automatically download e-books from Anna's Archive to accompany your audiobooks.
Automatically download e-books to accompany your audiobooks.
E-books are placed in the same folder as the audiobook files.
</p>
</div>
{/* Enable Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="ebook-enabled"
checked={ebook.enabled || false}
onChange={(e) => updateEbook('enabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="ebook-enabled"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Enable e-book sidecar downloads
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When enabled, the system will search for e-books matching your audiobook's ASIN
and download them to the same folder.
</p>
{/* ═══════════════════════════════════════════════════════════════════════
SECTION 1: ANNA'S ARCHIVE
═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
Anna's Archive
</h3>
</div>
<div className="p-4 space-y-4">
{/* Enable Toggle */}
<div className="flex items-start gap-4">
<input
type="checkbox"
id="annas-archive-enabled"
checked={ebook.annasArchiveEnabled || false}
onChange={(e) => updateEbook('annasArchiveEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="annas-archive-enabled"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Enable Anna's Archive downloads
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Download e-books directly from Anna's Archive using ASIN or title matching.
</p>
</div>
</div>
{/* Anna's Archive specific settings - only shown when enabled */}
{ebook.annasArchiveEnabled && (
<>
{/* Base URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Base URL
</label>
<Input
type="text"
value={ebook.baseUrl || 'https://annas-archive.li'}
onChange={(e) => updateEbook('baseUrl', e.target.value)}
placeholder="https://annas-archive.li"
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Change this if the primary Anna's Archive mirror is unavailable.
</p>
</div>
{/* FlareSolverr URL */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
FlareSolverr URL (Optional)
</label>
<div className="flex gap-2">
<Input
type="text"
value={ebook.flaresolverrUrl || ''}
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
placeholder="http://localhost:8191"
className="font-mono flex-1"
/>
<Button
onClick={testFlaresolverrConnection}
loading={testingFlaresolverr}
variant="secondary"
className="whitespace-nowrap"
>
Test
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
FlareSolverr helps bypass Cloudflare protection.
</p>
{flaresolverrTestResult && (
<div
className={`mt-2 p-3 rounded-lg text-sm ${
flaresolverrTestResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
}`}
>
{flaresolverrTestResult.success ? ' ' : ' '}
{flaresolverrTestResult.message}
</div>
)}
</div>
{!ebook.flaresolverrUrl && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
has Cloudflare protection enabled.
</p>
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Format Selection */}
{ebook.enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preferred Format
</label>
<select
value={ebook.preferredFormat || 'epub'}
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="epub">EPUB</option>
<option value="pdf">PDF</option>
<option value="mobi">MOBI</option>
<option value="azw3">AZW3</option>
<option value="any">Any format</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
EPUB is recommended for most e-readers. "Any format" will download the first available format.
</p>
{/* ═══════════════════════════════════════════════════════════════════════
SECTION 2: INDEXER SEARCH
═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
Indexer Search
</h3>
</div>
)}
{/* Base URL (Advanced) */}
{ebook.enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Base URL (Advanced)
</label>
<Input
type="text"
value={ebook.baseUrl || 'https://annas-archive.li'}
onChange={(e) => updateEbook('baseUrl', e.target.value)}
placeholder="https://annas-archive.li"
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Change this if the primary Anna's Archive mirror is unavailable.
</p>
</div>
)}
{/* FlareSolverr (Optional - for Cloudflare bypass) */}
{ebook.enabled && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
FlareSolverr URL (Optional)
</label>
<div className="flex gap-2">
<Input
type="text"
value={ebook.flaresolverrUrl || ''}
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
placeholder="http://localhost:8191"
className="font-mono flex-1"
/>
<Button
onClick={testFlaresolverrConnection}
loading={testingFlaresolverr}
variant="secondary"
className="whitespace-nowrap"
<div className="p-4 space-y-4">
{/* Enable Toggle */}
<div className="flex items-start gap-4">
<input
type="checkbox"
id="indexer-search-enabled"
checked={ebook.indexerSearchEnabled || false}
onChange={(e) => updateEbook('indexerSearchEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="indexer-search-enabled"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Test Connection
</Button>
Enable Indexer Search
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Search for e-books via Prowlarr indexers (torrent/NZB sources).
</p>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
FlareSolverr helps bypass Cloudflare protection on Anna's Archive.
Leave empty if not needed.
</p>
{flaresolverrTestResult && (
<div
className={`mt-2 p-3 rounded-lg text-sm ${
flaresolverrTestResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
}`}
>
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
{flaresolverrTestResult.message}
</div>
)}
</div>
{!ebook.flaresolverrUrl && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
has Cloudflare protection enabled. Success rates are typically lower without it.
{/* Info hint about indexer settings */}
{ebook.indexerSearchEnabled && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Configure Categories:</strong> E-book category settings are configured per-indexer
in the <span className="font-medium">Indexers</span> tab. Look for the "EBook" tab when
editing an indexer.
</p>
</div>
)}
</div>
</div>
{/* ═══════════════════════════════════════════════════════════════════════
SECTION 3: GENERAL SETTINGS
═══════════════════════════════════════════════════════════════════════ */}
{isAnySourceEnabled && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
General Settings
</h3>
</div>
<div className="p-4 space-y-4">
{/* Preferred Format */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preferred Format
</label>
<select
value={ebook.preferredFormat || 'epub'}
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="epub">EPUB (Recommended)</option>
<option value="pdf">PDF</option>
<option value="mobi">MOBI</option>
<option value="azw3">AZW3</option>
<option value="any">Any format</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
EPUB is recommended for most e-readers. "Any format" accepts the first available.
</p>
</div>
{/* Auto Grab Toggle */}
<div className="flex items-start gap-4 pt-2">
<input
type="checkbox"
id="auto-grab-enabled"
checked={ebook.autoGrabEnabled ?? true}
onChange={(e) => updateEbook('autoGrabEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="auto-grab-enabled"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Automatically fetch ebooks
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When enabled, ebook requests are created automatically after audiobook downloads complete.
When disabled, use the "Fetch Ebook" button on completed requests.
</p>
</div>
</div>
</div>
</div>
)}
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
How it works
</h3>
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
<li>• Searches Anna's Archive in two ways:</li>
<li className="ml-4">1. First tries ASIN (exact match - most accurate)</li>
<li className="ml-4">2. Falls back to title + author (with book/language filters)</li>
<li> Downloads matching e-book in your preferred format</li>
<li> Places e-book file in the same folder as the audiobook</li>
<li> If no match is found or download fails, audiobook download continues normally</li>
<li> Completely optional and non-blocking</li>
</ul>
</div>
{/* Warning Box */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
Important Note
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility.
Ensure compliance with your local laws and regulations.
</p>
</div>
{/* How it works - only show when Anna's Archive is enabled */}
{ebook.annasArchiveEnabled && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
How Anna's Archive works
</h3>
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li> Searches by ASIN first (exact match), then title + author</li>
<li> Downloads matching e-book in your preferred format</li>
<li> Places e-book file in the same folder as the audiobook</li>
<li> If no match is found, audiobook download continues normally</li>
</ul>
</div>
)}
{/* Save Button */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
@@ -77,10 +77,12 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: ebook.enabled || false,
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
format: ebook.preferredFormat || 'epub',
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
flaresolverrUrl: ebook.flaresolverrUrl || '',
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
}),
});
@@ -98,6 +100,11 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
}
};
/**
* Helper to check if any ebook source is enabled
*/
const isAnySourceEnabled = ebook.annasArchiveEnabled || ebook.indexerSearchEnabled;
return {
saving,
testingFlaresolverr,
@@ -105,5 +112,6 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
updateEbook,
testFlaresolverrConnection,
saveSettings,
isAnySourceEnabled,
};
}
+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,
};
})
);
@@ -76,26 +76,67 @@ export async function POST(
// Update request based on action
if (action === 'approve') {
const jobQueue = getJobQueueService();
const isEbookRequest = existingRequest.type === 'ebook';
// Check if request has a pre-selected torrent (from interactive search)
if (existingRequest.selectedTorrent) {
const selectedTorrent = existingRequest.selectedTorrent as any;
// User pre-selected a specific torrent - download that torrent directly
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
requestId: id,
userId: existingRequest.userId,
adminId: req.user.sub,
type: existingRequest.type,
source: selectedTorrent.source,
});
// Trigger download job with pre-selected torrent
await jobQueue.addDownloadJob(
existingRequest.id,
{
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
},
existingRequest.selectedTorrent as any
);
// Handle ebook requests with Anna's Archive source differently
if (isEbookRequest && selectedTorrent.source === 'annas_archive') {
// Create download history record for Anna's Archive
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId: existingRequest.id,
indexerName: "Anna's Archive",
torrentName: `${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`,
torrentSizeBytes: null,
qualityScore: selectedTorrent.score || 100,
selected: true,
downloadClient: 'direct',
downloadStatus: 'queued',
},
});
// Store all download URLs for retry purposes
if (selectedTorrent.downloadUrls && selectedTorrent.downloadUrls.length > 0) {
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: {
torrentUrl: JSON.stringify(selectedTorrent.downloadUrls),
},
});
}
// Trigger direct download job for Anna's Archive
await jobQueue.addStartDirectDownloadJob(
existingRequest.id,
downloadHistory.id,
selectedTorrent.downloadUrl,
`${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`,
undefined
);
} else {
// Trigger download job with pre-selected torrent (audiobook or indexer ebook)
await jobQueue.addDownloadJob(
existingRequest.id,
{
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
},
selectedTorrent
);
}
// Update status to 'downloading' and clear selectedTorrent
const updatedRequest = await prisma.request.update({
@@ -119,7 +160,7 @@ export async function POST(
await jobQueue.addNotificationJob(
'request_approved',
updatedRequest.id,
existingRequest.audiobook.title,
isEbookRequest ? `${existingRequest.audiobook.title} (Ebook)` : existingRequest.audiobook.title,
existingRequest.audiobook.author,
existingRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
@@ -131,6 +172,7 @@ export async function POST(
userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub,
type: existingRequest.type,
});
return NextResponse.json({
@@ -144,6 +186,7 @@ export async function POST(
requestId: id,
userId: existingRequest.userId,
adminId: req.user.sub,
type: existingRequest.type,
});
const updatedRequest = await prisma.request.update({
@@ -160,19 +203,28 @@ export async function POST(
},
});
// Trigger search job
await jobQueue.addSearchJob(updatedRequest.id, {
id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined,
});
// Trigger appropriate search job based on request type
if (isEbookRequest) {
await jobQueue.addSearchEbookJob(updatedRequest.id, {
id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined,
});
} else {
await jobQueue.addSearchJob(updatedRequest.id, {
id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined,
});
}
// Send notification for manual approval
await jobQueue.addNotificationJob(
'request_approved',
updatedRequest.id,
updatedRequest.audiobook.title,
isEbookRequest ? `${updatedRequest.audiobook.title} (Ebook)` : updatedRequest.audiobook.title,
updatedRequest.audiobook.author,
updatedRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
@@ -184,11 +236,14 @@ export async function POST(
userId: updatedRequest.userId,
audiobookTitle: updatedRequest.audiobook.title,
adminId: req.user.sub,
type: existingRequest.type,
});
return NextResponse.json({
success: true,
message: 'Request approved and search job triggered',
message: isEbookRequest
? 'Ebook request approved and ebook search job triggered'
: 'Request approved and search job triggered',
request: updatedRequest,
});
}
@@ -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,
+1
View File
@@ -130,6 +130,7 @@ export async function GET(request: NextRequest) {
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
type: request.type || 'audiobook', // Include request type for UI display
userId: request.user.id,
user: request.user.plexUsername,
createdAt: request.createdAt,
+26 -8
View File
@@ -13,8 +13,11 @@ 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();
// Parse request body - new structure with separate source toggles
const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl, autoGrabEnabled } = await request.json();
// Enforce: auto-grab must be false if no sources are enabled
const effectiveAutoGrabEnabled = (annasArchiveEnabled || indexerSearchEnabled) ? (autoGrabEnabled ?? true) : false;
// Validate format
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
@@ -25,8 +28,8 @@ export async function PUT(request: NextRequest) {
);
}
// Validate baseUrl (basic check)
if (baseUrl && !baseUrl.startsWith('http')) {
// Validate baseUrl (basic check) - only required if Anna's Archive is enabled
if (annasArchiveEnabled && baseUrl && !baseUrl.startsWith('http')) {
return NextResponse.json(
{ error: 'Base URL must start with http:// or https://' },
{ status: 400 }
@@ -46,23 +49,38 @@ export async function PUT(request: NextRequest) {
const configService = getConfigService();
const configs = [
// New granular source toggles
{
key: 'ebook_sidecar_enabled',
value: enabled ? 'true' : 'false',
key: 'ebook_annas_archive_enabled',
value: annasArchiveEnabled ? 'true' : 'false',
category: 'ebook',
description: 'Enable e-book sidecar downloads from Annas Archive',
description: 'Enable e-book downloads from Anna\'s Archive',
},
{
key: 'ebook_indexer_search_enabled',
value: indexerSearchEnabled ? 'true' : 'false',
category: 'ebook',
description: 'Enable e-book downloads via indexer search (Prowlarr)',
},
// General settings
{
key: 'ebook_sidecar_preferred_format',
value: format || 'epub',
category: 'ebook',
description: 'Preferred e-book format',
},
{
key: 'ebook_auto_grab_enabled',
value: effectiveAutoGrabEnabled ? 'true' : 'false',
category: 'ebook',
description: 'Automatically create ebook requests after audiobook downloads complete',
},
// Anna's Archive specific settings
{
key: 'ebook_sidecar_base_url',
value: baseUrl || 'https://annas-archive.li',
category: 'ebook',
description: 'Base URL for Annas Archive',
description: 'Base URL for Anna\'s Archive',
},
{
key: 'ebook_sidecar_flaresolverr_url',
@@ -19,7 +19,9 @@ interface SavedIndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean;
categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
ebookCategories?: number[]; // Array of category IDs for ebooks (default: [7020])
categories?: number[]; // Legacy field for migration
}
/**
@@ -54,6 +56,12 @@ export async function GET(request: NextRequest) {
const isAdded = !!saved;
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
// Migration: if old 'categories' field exists but new fields don't, migrate
const migratedAudiobookCategories = saved?.audiobookCategories ||
saved?.categories || // Legacy migration
[3030]; // Default to audiobooks category
const migratedEbookCategories = saved?.ebookCategories || [7020]; // Default to ebooks category
const config: any = {
id: indexer.id,
name: indexer.name,
@@ -63,7 +71,8 @@ export async function GET(request: NextRequest) {
isAdded, // Explicit flag for UI (new card-based interface)
priority: saved?.priority || 10,
rssEnabled: saved?.rssEnabled ?? false,
categories: saved?.categories || [3030], // Default to audiobooks category
audiobookCategories: migratedAudiobookCategories,
ebookCategories: migratedEbookCategories,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
@@ -117,7 +126,8 @@ export async function PUT(request: NextRequest) {
protocol: indexer.protocol,
priority: indexer.priority,
rssEnabled: indexer.rssEnabled || false,
categories: indexer.categories || [3030], // Default to audiobooks if not specified
audiobookCategories: indexer.audiobookCategories || [3030], // Default to audiobooks
ebookCategories: indexer.ebookCategories || [7020], // Default to ebooks
};
// Add protocol-specific fields
+10 -2
View File
@@ -129,10 +129,18 @@ export async function GET(request: NextRequest) {
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
ebook: {
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
annasArchiveEnabled: configMap.get('ebook_annas_archive_enabled') === 'true' ||
// Migration: if old key is true and new key doesn't exist, use old value
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
// Anna's Archive specific settings
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
// General settings
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
// Auto-grab: default true to preserve existing behavior
autoGrabEnabled: configMap.get('ebook_auto_grab_enabled') !== 'false',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
@@ -0,0 +1,113 @@
/**
* Component: Ebook Status API Route
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Returns ebook availability status for a specific audiobook
* Used by AudiobookDetailsModal to determine if ebook buttons should be shown
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Audiobooks.EbookStatus');
// Statuses that indicate an active/in-progress ebook request
const ACTIVE_EBOOK_STATUSES = [
'pending',
'awaiting_approval',
'searching',
'downloading',
'processing',
'downloaded',
'available',
];
/**
* GET /api/audiobooks/[asin]/ebook-status
* Returns whether ebook sources are enabled and if an active ebook request exists
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{ error: 'Valid ASIN is required' },
{ status: 400 }
);
}
// Check which ebook sources are enabled
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
]);
// Legacy migration: check old key if new keys don't exist
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
const ebookSourcesEnabled = isAnnasArchiveEnabled || isIndexerSearchEnabled;
// If no ebook sources enabled, return early
if (!ebookSourcesEnabled) {
return NextResponse.json({
ebookSourcesEnabled: false,
hasActiveEbookRequest: false,
existingEbookStatus: null,
});
}
// Find the audiobook by ASIN
const audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
select: { id: true },
});
if (!audiobook) {
// Audiobook not in database - that's fine, just no ebook request possible
return NextResponse.json({
ebookSourcesEnabled: true,
hasActiveEbookRequest: false,
existingEbookStatus: null,
});
}
// Check for any active ebook request for this audiobook
const existingEbookRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'ebook',
deletedAt: null,
status: { in: ACTIVE_EBOOK_STATUSES },
},
select: {
id: true,
status: true,
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
ebookSourcesEnabled: true,
hasActiveEbookRequest: !!existingEbookRequest,
existingEbookStatus: existingEbookRequest?.status || null,
existingEbookRequestId: existingEbookRequest?.id || null,
});
} catch (error) {
logger.error('Failed to get ebook status', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'Failed to fetch ebook status' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,336 @@
/**
* Component: Fetch Ebook by ASIN API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Creates an ebook request for an available audiobook (by ASIN)
* Supports both audiobooks with parent requests and orphan audiobooks (imported outside RMAB)
* Includes approval logic for non-admin users
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Audiobooks.FetchEbook');
// Statuses that indicate an active/in-progress ebook request
const ACTIVE_EBOOK_STATUSES = [
'pending',
'awaiting_approval',
'searching',
'downloading',
'processing',
'downloaded',
'available',
];
// Statuses that allow retry
const RETRYABLE_STATUSES = ['failed', 'awaiting_search'];
/**
* POST /api/audiobooks/[asin]/fetch-ebook
* Create an ebook request for an available audiobook
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{ error: 'Valid ASIN is required' },
{ status: 400 }
);
}
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check which ebook sources are enabled
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
]);
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'E-book feature is not enabled (no sources configured)' },
{ status: 400 }
);
}
// First, check if the audiobook is available in Plex library
// This works even for books imported outside RMAB
const audibleService = getAudibleService();
let audibleData = null;
try {
audibleData = await audibleService.getAudiobookDetails(asin);
} catch (error) {
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!audibleData) {
return NextResponse.json(
{ error: 'Audiobook not found on Audible' },
{ status: 404 }
);
}
// Check Plex availability using Audible metadata
const plexMatch = await findPlexMatch({
asin,
title: audibleData.title,
author: audibleData.author,
});
// Find or create audiobook record
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
// Check for available request if audiobook exists in database
let availableRequest = null;
if (audiobook) {
availableRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
}
const isAvailable = !!availableRequest || !!plexMatch;
if (!isAvailable) {
return NextResponse.json(
{ error: 'Audiobook must be available in your library before requesting an ebook' },
{ status: 400 }
);
}
// If audiobook doesn't exist in database but is in Plex, create it
if (!audiobook) {
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
// Extract year from release date
let year: number | undefined;
if (audibleData.releaseDate) {
try {
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parsing errors
}
}
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: audibleData.title,
author: audibleData.author,
narrator: audibleData.narrator,
description: audibleData.description,
coverArtUrl: audibleData.coverArtUrl,
year,
series: audibleData.series,
seriesPart: audibleData.seriesPart,
status: 'available', // Mark as available since it's in Plex
},
});
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
}
// Check for existing ebook request for this audiobook
const existingEbookRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'ebook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
// Handle existing ebook request
if (existingEbookRequest) {
// If in active status, block
if (ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status)) {
return NextResponse.json({
success: false,
message: `E-book request already exists (status: ${existingEbookRequest.status})`,
requestId: existingEbookRequest.id,
}, { status: 409 });
}
// If retryable, reset and retry
if (RETRYABLE_STATUSES.includes(existingEbookRequest.status)) {
await prisma.request.update({
where: { id: existingEbookRequest.id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
});
const jobQueue = getJobQueueService();
await jobQueue.addSearchEbookJob(existingEbookRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${audiobook.title}"`);
return NextResponse.json({
success: true,
message: 'E-book search retried',
requestId: existingEbookRequest.id,
});
}
}
// Check if approval is needed for non-admin users
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
plexUsername: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
let needsApproval = false;
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
const jobQueue = getJobQueueService();
if (needsApproval) {
// Create ebook request with awaiting_approval status
const ebookRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobook.id,
type: 'ebook',
parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval',
progress: 0,
},
});
// Send pending approval notification
await jobQueue.addNotificationJob(
'request_pending_approval',
ebookRequest.id,
`${audiobook.title} (Ebook)`,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Ebook request ${ebookRequest.id} created, awaiting admin approval`);
return NextResponse.json({
success: true,
message: 'Ebook request submitted for admin approval',
requestId: ebookRequest.id,
needsApproval: true,
}, { status: 201 });
} else {
// Auto-approved - create request and start search
const ebookRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobook.id,
type: 'ebook',
parentRequestId: availableRequest?.id || null,
status: 'pending',
progress: 0,
},
});
logger.info(`Created ebook request ${ebookRequest.id} for "${audiobook.title}"`);
// Trigger ebook search job
await jobQueue.addSearchEbookJob(ebookRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
// Send approved notification
await jobQueue.addNotificationJob(
'request_approved',
ebookRequest.id,
`${audiobook.title} (Ebook)`,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
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,
needsApproval: false,
}, { status: 201 });
}
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,477 @@
/**
* Component: Interactive Search Ebook by ASIN API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Searches for ebooks from multiple sources (Anna's Archive + Indexers)
* Returns combined results for user selection in interactive modal
* User-accessible endpoint (not admin-only)
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import {
searchByAsin,
searchByTitle,
getSlowDownloadLinks,
} from '@/lib/services/ebook-scraper';
const logger = RMABLogger.create('API.Audiobooks.InteractiveSearchEbook');
// Statuses that indicate an active/in-progress ebook request
const ACTIVE_EBOOK_STATUSES = [
'pending',
'awaiting_approval',
'searching',
'downloading',
'processing',
'downloaded',
'available',
];
// Statuses that allow retry via interactive search
const RETRYABLE_STATUSES = ['failed', 'awaiting_search'];
// Unified result type for frontend
export interface EbookSearchResult {
guid: string;
title: string;
size: number;
seeders?: number;
indexer: string;
indexerId?: number;
publishDate: Date;
downloadUrl: string;
infoUrl?: string;
protocol?: string;
score: number;
finalScore: number;
bonusPoints: number;
bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>;
rank: number;
breakdown: {
formatScore: number;
sizeScore: number;
seederScore: number;
matchScore: number;
totalScore: number;
notes: string[];
};
source: 'annas_archive' | 'prowlarr';
format?: string;
md5?: string;
downloadUrls?: string[];
}
/**
* POST /api/audiobooks/[asin]/interactive-search-ebook
* Search for ebooks and return results for user selection
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{ error: 'Valid ASIN is required' },
{ status: 400 }
);
}
// First, fetch audiobook data from Audible (works for books imported outside RMAB)
const audibleService = getAudibleService();
let audibleData = null;
try {
audibleData = await audibleService.getAudiobookDetails(asin);
} catch (error) {
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!audibleData) {
return NextResponse.json(
{ error: 'Audiobook not found on Audible' },
{ status: 404 }
);
}
// Check Plex availability using Audible metadata
const plexMatch = await findPlexMatch({
asin,
title: audibleData.title,
author: audibleData.author,
});
// Find or create audiobook record
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
// Check for available request if audiobook exists in database
let availableRequest = null;
if (audiobook) {
availableRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
}
const isAvailable = !!availableRequest || !!plexMatch;
if (!isAvailable) {
return NextResponse.json(
{ error: 'Audiobook must be available in your library before searching for ebooks' },
{ status: 400 }
);
}
// If audiobook doesn't exist in database but is in Plex, create it
if (!audiobook) {
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
// Extract year from release date
let year: number | undefined;
if (audibleData.releaseDate) {
try {
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parsing errors
}
}
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: audibleData.title,
author: audibleData.author,
narrator: audibleData.narrator,
description: audibleData.description,
coverArtUrl: audibleData.coverArtUrl,
year,
series: audibleData.series,
seriesPart: audibleData.seriesPart,
status: 'available',
},
});
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
}
// Check for existing non-retryable ebook request
const existingEbookRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'ebook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
if (existingEbookRequest &&
ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status) &&
!RETRYABLE_STATUSES.includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
}
// Get ebook configuration
const configService = getConfigService();
const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([
configService.get('ebook_annas_archive_enabled'),
configService.get('ebook_indexer_search_enabled'),
configService.get('ebook_sidecar_preferred_format'),
configService.get('ebook_sidecar_base_url'),
configService.get('ebook_sidecar_flaresolverr_url'),
]);
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
{ status: 400 }
);
}
const searchTitle = customTitle || audiobook.title;
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
// Search both sources in parallel
const searchPromises: Promise<EbookSearchResult[] | null>[] = [];
if (isAnnasArchiveEnabled) {
searchPromises.push(
searchAnnasArchiveForInteractive(
audiobook.audibleAsin || undefined,
searchTitle,
audiobook.author,
format,
annasBaseUrl,
flaresolverrUrl || undefined
).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`);
return null;
})
);
}
if (isIndexerSearchEnabled) {
searchPromises.push(
searchIndexersForInteractive(
searchTitle,
audiobook.author,
format
).catch((err) => {
logger.error(`Indexer search failed: ${err.message}`);
return null;
})
);
}
const searchResults = await Promise.all(searchPromises);
// Combine results: Anna's Archive first (if found), then ranked indexer results
const combinedResults: EbookSearchResult[] = [];
let rank = 1;
// Add Anna's Archive result first (if enabled and found)
if (isAnnasArchiveEnabled && searchResults[0]) {
const annasResults = searchResults[0];
for (const result of annasResults) {
combinedResults.push({ ...result, rank: rank++ });
}
}
// Add indexer results (already ranked)
const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0;
if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) {
const indexerResults = searchResults[indexerResultsIndex];
for (const result of indexerResults) {
combinedResults.push({ ...result, rank: rank++ });
}
}
logger.info(`Found ${combinedResults.length} total ebook results`);
return NextResponse.json({
results: combinedResults,
searchTitle,
preferredFormat: format,
audiobookId: audiobook.id,
});
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
}
/**
* Search Anna's Archive and return normalized results
*/
async function searchAnnasArchiveForInteractive(
asin: string | undefined,
title: string,
author: string,
preferredFormat: string,
baseUrl: string,
flaresolverrUrl?: string
): Promise<EbookSearchResult[]> {
let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title';
// Try ASIN search first
if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
if (md5) {
searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`);
}
}
// Fallback to title search
if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
if (md5) {
logger.info(`Found via title: ${md5}`);
}
}
if (!md5) {
logger.info('No results from Anna\'s Archive');
return [];
}
// Get download links
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl);
if (slowLinks.length === 0) {
logger.warn(`Found MD5 ${md5} but no download links available`);
return [];
}
// Return as normalized result - always score 100 for Anna's Archive
const score = 100;
return [{
guid: `annas-archive-${md5}`,
title: `${title} - ${author}`,
size: 0,
seeders: 999,
indexer: "Anna's Archive",
publishDate: new Date(),
downloadUrl: slowLinks[0],
infoUrl: `${baseUrl}/md5/${md5}`,
score,
finalScore: score,
bonusPoints: 0,
bonusModifiers: [],
rank: 1,
breakdown: {
formatScore: 10,
sizeScore: 15,
seederScore: 15,
matchScore: 60,
totalScore: score,
notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"],
},
source: 'annas_archive',
format: preferredFormat,
md5,
downloadUrls: slowLinks,
}];
}
/**
* Search indexers and return ranked results
*/
async function searchIndexersForInteractive(
title: string,
author: string,
preferredFormat: string
): Promise<EbookSearchResult[]> {
const configService = getConfigService();
// Get indexer configuration
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
logger.warn('No indexers configured');
return [];
}
const indexersConfig = JSON.parse(indexersConfigStr);
if (indexersConfig.length === 0) {
logger.warn('No indexers enabled');
return [];
}
// Build indexer priorities map
const indexerPriorities = new Map<number, number>(
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
);
// Get flag configurations
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Search each group and combine results
const allResults = [];
for (const group of groups) {
try {
const groupResults = await prowlarr.search(title, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 0,
maxResults: 100,
});
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`);
}
}
logger.info(`Found ${allResults.length} results from indexers`);
if (allResults.length === 0) {
return [];
}
// Rank results with ebook scoring
const rankedResults = rankEbookTorrents(allResults, {
title,
author,
preferredFormat,
}, {
indexerPriorities,
flagConfigs,
requireAuthor: false,
});
// Convert to unified result type
return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({
guid: result.guid,
title: result.title,
size: result.size,
seeders: result.seeders,
indexer: result.indexer,
indexerId: result.indexerId,
publishDate: result.publishDate,
downloadUrl: result.downloadUrl,
infoUrl: result.infoUrl,
score: result.score,
finalScore: result.finalScore,
bonusPoints: result.bonusPoints,
bonusModifiers: result.bonusModifiers,
rank: result.rank,
breakdown: result.breakdown,
source: 'prowlarr',
format: result.ebookFormat,
protocol: result.protocol,
}));
}
@@ -0,0 +1,445 @@
/**
* Component: Select Ebook by ASIN API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Creates an ebook request with a user-selected source (Anna's Archive or indexer)
* Routes to appropriate download processor based on source type
* Includes approval logic for non-admin users
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Audiobooks.SelectEbook');
// Statuses that indicate an active/in-progress ebook request
const ACTIVE_EBOOK_STATUSES = [
'pending',
'awaiting_approval',
'searching',
'downloading',
'processing',
'downloaded',
'available',
];
// Statuses that allow reuse
const REUSABLE_STATUSES = ['failed', 'awaiting_search', 'pending'];
interface SelectedEbook {
guid: string;
title: string;
size: number;
seeders: number;
indexer: string;
indexerId?: number;
downloadUrl: string;
infoUrl?: string;
score: number;
finalScore: number;
source: 'annas_archive' | 'prowlarr';
format?: string;
md5?: string;
downloadUrls?: string[];
protocol?: string;
}
/**
* POST /api/audiobooks/[asin]/select-ebook
* Select and download an ebook from interactive search results
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
const body = await request.json();
const selectedEbook = body.ebook as SelectedEbook;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{ error: 'Valid ASIN is required' },
{ status: 400 }
);
}
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (!selectedEbook) {
return NextResponse.json({ error: 'No ebook selected' }, { status: 400 });
}
if (!selectedEbook.source) {
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// First, fetch audiobook data from Audible (works for books imported outside RMAB)
const audibleService = getAudibleService();
let audibleData = null;
try {
audibleData = await audibleService.getAudiobookDetails(asin);
} catch (error) {
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!audibleData) {
return NextResponse.json(
{ error: 'Audiobook not found on Audible' },
{ status: 404 }
);
}
// Check Plex availability using Audible metadata
const plexMatch = await findPlexMatch({
asin,
title: audibleData.title,
author: audibleData.author,
});
// Find or create audiobook record
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
// Check for available request if audiobook exists in database
let availableRequest = null;
if (audiobook) {
availableRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
}
const isAvailable = !!availableRequest || !!plexMatch;
if (!isAvailable) {
return NextResponse.json(
{ error: 'Audiobook must be available in your library before requesting an ebook' },
{ status: 400 }
);
}
// If audiobook doesn't exist in database but is in Plex, create it
if (!audiobook) {
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
// Extract year from release date
let year: number | undefined;
if (audibleData.releaseDate) {
try {
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parsing errors
}
}
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: audibleData.title,
author: audibleData.author,
narrator: audibleData.narrator,
description: audibleData.description,
coverArtUrl: audibleData.coverArtUrl,
year,
series: audibleData.series,
seriesPart: audibleData.seriesPart,
status: 'available',
},
});
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'ebook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
// Handle existing ebook request
if (ebookRequest) {
if (ACTIVE_EBOOK_STATUSES.includes(ebookRequest.status) &&
!REUSABLE_STATUSES.includes(ebookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${ebookRequest.status})`,
existingRequestId: ebookRequest.id,
}, { status: 400 });
}
}
// Check if approval is needed for non-admin users
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
plexUsername: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
let needsApproval = false;
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
const jobQueue = getJobQueueService();
if (needsApproval) {
// Create or update ebook request with awaiting_approval status
if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) {
ebookRequest = await prisma.request.update({
where: { id: ebookRequest.id },
data: {
status: 'awaiting_approval',
progress: 0,
errorMessage: null,
selectedTorrent: selectedEbook as any, // Store selected ebook for later
updatedAt: new Date(),
},
});
logger.info(`Reusing ebook request ${ebookRequest.id}, awaiting approval`);
} else {
ebookRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobook.id,
type: 'ebook',
parentRequestId: availableRequest?.id || null,
status: 'awaiting_approval',
progress: 0,
selectedTorrent: selectedEbook as any,
},
});
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
}
// Send pending approval notification
await jobQueue.addNotificationJob(
'request_pending_approval',
ebookRequest.id,
`${audiobook.title} (Ebook)`,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
return NextResponse.json({
success: true,
message: 'Ebook request submitted for admin approval',
requestId: ebookRequest.id,
needsApproval: true,
}, { status: 201 });
} else {
// Auto-approved - create or update request and start download
if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) {
ebookRequest = await prisma.request.update({
where: { id: ebookRequest.id },
data: {
status: 'searching',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
});
logger.info(`Reusing existing ebook request ${ebookRequest.id}`);
} else {
ebookRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobook.id,
type: 'ebook',
parentRequestId: availableRequest?.id || null,
status: 'searching',
progress: 0,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
}
// Route to appropriate download based on source
if (selectedEbook.source === 'annas_archive') {
await handleAnnasArchiveDownload(
ebookRequest.id,
audiobook,
selectedEbook,
jobQueue
);
} else {
await handleIndexerDownload(
ebookRequest.id,
audiobook,
selectedEbook,
jobQueue
);
}
// Send approved notification
await jobQueue.addNotificationJob(
'request_approved',
ebookRequest.id,
`${audiobook.title} (Ebook)`,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
return NextResponse.json({
success: true,
message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`,
requestId: ebookRequest.id,
needsApproval: false,
});
}
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
}
/**
* Handle Anna's Archive download (direct HTTP)
*/
async function handleAnnasArchiveDownload(
requestId: string,
audiobook: { id: string; title: string; author: string },
selectedEbook: SelectedEbook,
jobQueue: ReturnType<typeof getJobQueueService>
) {
const configService = getConfigService();
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
logger.info(`Starting Anna's Archive download for "${audiobook.title}"`);
logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`);
// Create download history record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: "Anna's Archive",
torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
torrentSizeBytes: null,
qualityScore: selectedEbook.score,
selected: true,
downloadClient: 'direct',
downloadStatus: 'queued',
},
});
// Store all download URLs for retry purposes
if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) {
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: {
torrentUrl: JSON.stringify(selectedEbook.downloadUrls),
},
});
}
// Trigger direct download job
await jobQueue.addStartDirectDownloadJob(
requestId,
downloadHistory.id,
selectedEbook.downloadUrl,
`${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
undefined
);
logger.info(`Queued direct download job for request ${requestId}`);
}
/**
* Handle indexer download (torrent/NZB)
*/
async function handleIndexerDownload(
requestId: string,
audiobook: { id: string; title: string; author: string },
selectedEbook: SelectedEbook,
jobQueue: ReturnType<typeof getJobQueueService>
) {
logger.info(`Starting indexer download for "${audiobook.title}"`);
logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`);
const torrentForJob = {
guid: selectedEbook.guid,
title: selectedEbook.title,
size: selectedEbook.size,
seeders: selectedEbook.seeders || 0,
indexer: selectedEbook.indexer,
indexerId: selectedEbook.indexerId,
downloadUrl: selectedEbook.downloadUrl,
infoUrl: selectedEbook.infoUrl,
publishDate: new Date(),
score: selectedEbook.score,
finalScore: selectedEbook.finalScore,
bonusPoints: 0,
bonusModifiers: [],
rank: 1,
breakdown: {
formatScore: 0,
sizeScore: 0,
seederScore: 0,
matchScore: 0,
totalScore: selectedEbook.score,
notes: [],
},
protocol: selectedEbook.protocol,
};
await jobQueue.addDownloadJob(requestId, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
}, torrentForJob as any);
logger.info(`Queued download job for request ${requestId}`);
}
@@ -64,13 +64,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,
},
@@ -184,11 +185,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
},
});
@@ -266,6 +268,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
},
@@ -307,6 +310,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,
},
});
+95 -100
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,132 +20,130 @@ 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({
where: { key: 'ebook_sidecar_enabled' },
});
// Check which ebook sources are enabled
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
]);
if (ebookEnabledConfig?.value !== 'true') {
// Legacy migration: check old key if new keys don't exist
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
// If no sources are enabled, return error
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'E-book sidecar feature is not enabled' },
{ error: 'E-book sidecar feature is not enabled (no sources configured)' },
{ status: 400 }
);
}
// 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(
@@ -0,0 +1,430 @@
/**
* Component: Interactive Search Ebook API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Searches for ebooks from multiple sources (Anna's Archive + Indexers)
* Returns combined results for user selection in interactive modal
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger';
import {
searchByAsin,
searchByTitle,
getSlowDownloadLinks,
} from '@/lib/services/ebook-scraper';
const logger = RMABLogger.create('API.InteractiveSearchEbook');
// Unified result type for frontend
export interface EbookSearchResult {
// Common fields (match RankedTorrent shape for UI compatibility)
guid: string;
title: string;
size: number;
seeders?: number;
indexer: string;
indexerId?: number;
publishDate: Date;
downloadUrl: string;
infoUrl?: string;
protocol?: string; // 'torrent' or 'usenet' - determines download client
// Ranking fields
score: number;
finalScore: number;
bonusPoints: number;
bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>;
rank: number;
breakdown: {
formatScore: number;
sizeScore: number;
seederScore: number;
matchScore: number;
totalScore: number;
notes: string[];
};
// Ebook-specific fields
source: 'annas_archive' | 'prowlarr';
format?: string;
md5?: string;
downloadUrls?: string[];
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id: parentRequestId } = await params;
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
}
if (!['downloaded', 'available'].includes(parentRequest.status)) {
return NextResponse.json(
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
{ status: 400 }
);
}
// Check for existing non-retryable ebook request
const existingEbookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
existingRequestId: existingEbookRequest.id,
}, { status: 400 });
}
// Get ebook configuration
const configService = getConfigService();
const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([
configService.get('ebook_annas_archive_enabled'),
configService.get('ebook_indexer_search_enabled'),
configService.get('ebook_sidecar_preferred_format'),
configService.get('ebook_sidecar_base_url'),
configService.get('ebook_sidecar_flaresolverr_url'),
]);
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
{ status: 400 }
);
}
const audiobook = parentRequest.audiobook;
const searchTitle = customTitle || audiobook.title;
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
// Search both sources in parallel
const searchPromises: Promise<EbookSearchResult[] | null>[] = [];
if (isAnnasArchiveEnabled) {
searchPromises.push(
searchAnnasArchiveForInteractive(
audiobook.audibleAsin || undefined,
searchTitle,
audiobook.author,
format,
annasBaseUrl,
flaresolverrUrl || undefined
).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`);
return null;
})
);
}
if (isIndexerSearchEnabled) {
searchPromises.push(
searchIndexersForInteractive(
searchTitle,
audiobook.author,
format
).catch((err) => {
logger.error(`Indexer search failed: ${err.message}`);
return null;
})
);
}
const searchResults = await Promise.all(searchPromises);
// Combine results: Anna's Archive first (if found), then ranked indexer results
const combinedResults: EbookSearchResult[] = [];
let rank = 1;
// Add Anna's Archive result first (if enabled and found)
if (isAnnasArchiveEnabled && searchResults[0]) {
const annasResults = searchResults[0];
for (const result of annasResults) {
combinedResults.push({ ...result, rank: rank++ });
}
}
// Add indexer results (already ranked)
const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0;
if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) {
const indexerResults = searchResults[indexerResultsIndex];
for (const result of indexerResults) {
combinedResults.push({ ...result, rank: rank++ });
}
}
logger.info(`Found ${combinedResults.length} total ebook results`);
return NextResponse.json({
results: combinedResults,
searchTitle,
preferredFormat: format,
});
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
});
}
/**
* Search Anna's Archive and return normalized results
*/
async function searchAnnasArchiveForInteractive(
asin: string | undefined,
title: string,
author: string,
preferredFormat: string,
baseUrl: string,
flaresolverrUrl?: string
): Promise<EbookSearchResult[]> {
let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title';
// Try ASIN search first
if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
if (md5) {
searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`);
}
}
// Fallback to title search
if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
if (md5) {
logger.info(`Found via title: ${md5}`);
}
}
if (!md5) {
logger.info('No results from Anna\'s Archive');
return [];
}
// Get download links
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl);
if (slowLinks.length === 0) {
logger.warn(`Found MD5 ${md5} but no download links available`);
return [];
}
// Return as normalized result - always score 100 for Anna's Archive
const score = 100;
return [{
guid: `annas-archive-${md5}`,
title: `${title} - ${author}`,
size: 0, // Unknown until download
seeders: 999, // N/A for direct download, use high number for display
indexer: "Anna's Archive",
publishDate: new Date(),
downloadUrl: slowLinks[0],
infoUrl: `${baseUrl}/md5/${md5}`,
score,
finalScore: score,
bonusPoints: 0,
bonusModifiers: [],
rank: 1,
breakdown: {
formatScore: 10,
sizeScore: 15,
seederScore: 15,
matchScore: 60,
totalScore: score,
notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"],
},
source: 'annas_archive',
format: preferredFormat,
md5,
downloadUrls: slowLinks,
}];
}
/**
* Search indexers and return ranked results
*/
async function searchIndexersForInteractive(
title: string,
author: string,
preferredFormat: string
): Promise<EbookSearchResult[]> {
const configService = getConfigService();
// Get indexer configuration
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
logger.warn('No indexers configured');
return [];
}
const indexersConfig = JSON.parse(indexersConfigStr);
if (indexersConfig.length === 0) {
logger.warn('No indexers enabled');
return [];
}
// Build indexer priorities map
const indexerPriorities = new Map<number, number>(
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
);
// Get flag configurations
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Search each group and combine results
const allResults = [];
for (const group of groups) {
try {
const groupResults = await prowlarr.search(title, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 0,
maxResults: 100,
});
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`);
}
}
logger.info(`Found ${allResults.length} results from indexers`);
if (allResults.length === 0) {
return [];
}
// Rank results with ebook scoring
// Use requireAuthor=false for interactive mode (let user decide)
const rankedResults = rankEbookTorrents(allResults, {
title,
author,
preferredFormat,
}, {
indexerPriorities,
flagConfigs,
requireAuthor: false,
});
// Log ranking debug info (same format as search-ebook.processor.ts)
if (rankedResults.length > 0) {
const top3 = rankedResults.slice(0, 3);
logger.info(`==================== EBOOK INTERACTIVE SEARCH DEBUG ====================`);
logger.info(`Requested Title: "${title}"`);
logger.info(`Requested Author: "${author}"`);
logger.info(`Preferred Format: ${preferredFormat}`);
logger.info(`Top ${top3.length} results (out of ${rankedResults.length} total):`);
logger.info(`--------------------------------------------------------------`);
for (let i = 0; i < top3.length; i++) {
const result = top3[i];
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
logger.info(`${i + 1}. "${result.title}"`);
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
logger.info(` Format: ${result.ebookFormat || 'unknown'}`);
logger.info(``);
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`);
logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`);
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
logger.info(``);
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
for (const mod of result.bonusModifiers) {
logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
}
}
logger.info(``);
logger.info(` Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) {
logger.info(` Notes: ${result.breakdown.notes.join(', ')}`);
}
if (i < top3.length - 1) {
logger.info(`--------------------------------------------------------------`);
}
}
logger.info(`==============================================================`);
}
// Convert to unified result type
return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({
guid: result.guid,
title: result.title,
size: result.size,
seeders: result.seeders,
indexer: result.indexer,
indexerId: result.indexerId,
publishDate: result.publishDate,
downloadUrl: result.downloadUrl,
infoUrl: result.infoUrl,
score: result.score,
finalScore: result.finalScore,
bonusPoints: result.bonusPoints,
bonusModifiers: result.bonusModifiers,
rank: result.rank,
breakdown: result.breakdown,
source: 'prowlarr',
format: result.ebookFormat,
protocol: result.protocol,
}));
}
@@ -0,0 +1,258 @@
/**
* Component: Select Ebook API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Creates an ebook request with a user-selected source (Anna's Archive or indexer)
* Routes to appropriate download processor based on source type
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.SelectEbook');
interface SelectedEbook {
guid: string;
title: string;
size: number;
seeders: number;
indexer: string;
indexerId?: number;
downloadUrl: string;
infoUrl?: string;
score: number;
finalScore: number;
source: 'annas_archive' | 'prowlarr';
format?: string;
md5?: string;
downloadUrls?: string[];
protocol?: string; // 'torrent' or 'usenet' - determines download client
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id: parentRequestId } = await params;
const body = await request.json();
const selectedEbook = body.ebook as SelectedEbook;
if (!selectedEbook) {
return NextResponse.json({ error: 'No ebook selected' }, { status: 400 });
}
if (!selectedEbook.source) {
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
}
if (!['downloaded', 'available'].includes(parentRequest.status)) {
return NextResponse.json(
{ error: `Cannot select ebook for request in ${parentRequest.status} status` },
{ status: 400 }
);
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({
error: `E-book request already exists (status: ${ebookRequest.status})`,
existingRequestId: ebookRequest.id,
}, { status: 400 });
}
// Create or update ebook request
if (ebookRequest) {
// Reset existing failed/pending request
ebookRequest = await prisma.request.update({
where: { id: ebookRequest.id },
data: {
status: 'searching',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
});
logger.info(`Reusing existing ebook request ${ebookRequest.id}`);
} else {
// Create new ebook request
ebookRequest = await prisma.request.create({
data: {
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
status: 'searching',
progress: 0,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
}
const audiobook = parentRequest.audiobook;
const jobQueue = getJobQueueService();
// Route to appropriate download based on source
if (selectedEbook.source === 'annas_archive') {
// Anna's Archive: Direct HTTP download
await handleAnnasArchiveDownload(
ebookRequest.id,
audiobook,
selectedEbook,
jobQueue
);
} else {
// Indexer: Torrent/NZB download
await handleIndexerDownload(
ebookRequest.id,
audiobook,
selectedEbook,
jobQueue
);
}
return NextResponse.json({
success: true,
message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`,
requestId: ebookRequest.id,
});
} catch (error) {
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
});
}
/**
* Handle Anna's Archive download (direct HTTP)
*/
async function handleAnnasArchiveDownload(
requestId: string,
audiobook: { id: string; title: string; author: string },
selectedEbook: SelectedEbook,
jobQueue: ReturnType<typeof getJobQueueService>
) {
const configService = getConfigService();
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
logger.info(`Starting Anna's Archive download for "${audiobook.title}"`);
logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`);
// Create download history record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: "Anna's Archive",
torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
torrentSizeBytes: null, // Unknown until download starts
qualityScore: selectedEbook.score,
selected: true,
downloadClient: 'direct',
downloadStatus: 'queued',
},
});
// Store all download URLs for retry purposes
if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) {
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: {
torrentUrl: JSON.stringify(selectedEbook.downloadUrls),
},
});
}
// Trigger direct download job
await jobQueue.addStartDirectDownloadJob(
requestId,
downloadHistory.id,
selectedEbook.downloadUrl,
`${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
undefined // Size unknown
);
logger.info(`Queued direct download job for request ${requestId}`);
}
/**
* Handle indexer download (torrent/NZB)
*/
async function handleIndexerDownload(
requestId: string,
audiobook: { id: string; title: string; author: string },
selectedEbook: SelectedEbook,
jobQueue: ReturnType<typeof getJobQueueService>
) {
logger.info(`Starting indexer download for "${audiobook.title}"`);
logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`);
// Convert to RankedTorrent shape expected by download job
// Note: format is omitted as ebook formats (epub, pdf) differ from audiobook formats (M4B, M4A, MP3)
const torrentForJob = {
guid: selectedEbook.guid,
title: selectedEbook.title,
size: selectedEbook.size,
seeders: selectedEbook.seeders || 0,
indexer: selectedEbook.indexer,
indexerId: selectedEbook.indexerId,
downloadUrl: selectedEbook.downloadUrl,
infoUrl: selectedEbook.infoUrl,
publishDate: new Date(),
score: selectedEbook.score,
finalScore: selectedEbook.finalScore,
bonusPoints: 0,
bonusModifiers: [],
rank: 1,
breakdown: {
formatScore: 0,
sizeScore: 0,
seederScore: 0,
matchScore: 0,
totalScore: selectedEbook.score,
notes: [],
},
protocol: selectedEbook.protocol, // Pass through protocol for torrent vs usenet routing
};
// Use the download job (same as audiobooks)
await jobQueue.addDownloadJob(requestId, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
}, torrentForJob as any); // Cast to any since ebook torrents don't have audiobook format field
logger.info(`Queued download job for request ${requestId}`);
}
+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;
+2 -1
View File
@@ -26,7 +26,8 @@ interface SelectedIndexer {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Categories for audiobook searches
ebookCategories: number[]; // Categories for ebook searches
}
export function ProwlarrStep({
@@ -16,12 +16,15 @@ import {
interface CategoryTreeViewProps {
selectedCategories: number[];
onChange: (categories: number[]) => void;
defaultCategories?: number[]; // Categories to show "Default" badge for (e.g., [3030] for audiobook, [7020] for ebook)
}
export function CategoryTreeView({
selectedCategories,
onChange,
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
}: CategoryTreeViewProps) {
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
const handleParentToggle = (parentId: number) => {
const childIds = getChildIds(parentId);
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
@@ -75,7 +78,7 @@ export function CategoryTreeView({
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{category.id}]
</span>
{category.id === 3030 && (
{isDefaultCategory(category.id) && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
@@ -109,7 +112,7 @@ export function CategoryTreeView({
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{child.id}]
</span>
{child.id === 3030 && (
{isDefaultCategory(child.id) && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
@@ -1,6 +1,9 @@
/**
* Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/
'use client';
@@ -10,7 +13,9 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView';
import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook';
interface IndexerConfigModalProps {
isOpen: boolean;
@@ -27,7 +32,8 @@ interface IndexerConfigModalProps {
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[];
ebookCategories: number[];
};
onSave: (config: {
id: number;
@@ -37,7 +43,8 @@ interface IndexerConfigModalProps {
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[];
ebookCategories: number[];
}) => void;
}
@@ -56,7 +63,8 @@ export function IndexerConfigModal({
seedingTimeMinutes: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
ebookCategories: DEFAULT_EBOOK_CATEGORIES,
};
// Form state
@@ -72,15 +80,24 @@ export function IndexerConfigModal({
const [rssEnabled, setRssEnabled] = useState(
initialConfig?.rssEnabled ?? defaults.rssEnabled
);
const [selectedCategories, setSelectedCategories] = useState<number[]>(
initialConfig?.categories ?? defaults.categories
// Dual category state
const [audiobookCategories, setAudiobookCategories] = useState<number[]>(
initialConfig?.audiobookCategories ?? defaults.audiobookCategories
);
const [ebookCategories, setEbookCategories] = useState<number[]>(
initialConfig?.ebookCategories ?? defaults.ebookCategories
);
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
categories?: string;
audiobookCategories?: string;
ebookCategories?: string;
}>({});
// Reset form when modal opens or indexer changes
@@ -91,14 +108,17 @@ export function IndexerConfigModal({
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setSelectedCategories(defaults.categories);
setAudiobookCategories(defaults.audiobookCategories);
setEbookCategories(defaults.ebookCategories);
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
setEbookCategories(initialConfig?.ebookCategories ?? defaults.ebookCategories);
}
setActiveTab('audiobook');
setErrors({});
}
}, [isOpen, mode, indexer.id]);
@@ -114,8 +134,12 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (selectedCategories.length === 0) {
newErrors.categories = 'At least one category must be selected';
if (audiobookCategories.length === 0) {
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
}
if (ebookCategories.length === 0) {
newErrors.ebookCategories = 'At least one ebook category must be selected';
}
setErrors(newErrors);
@@ -124,6 +148,12 @@ export function IndexerConfigModal({
const handleSave = () => {
if (!validate()) {
// If there's a category error, switch to the relevant tab
if (errors.audiobookCategories && activeTab !== 'audiobook') {
setActiveTab('audiobook');
} else if (errors.ebookCategories && activeTab !== 'ebook') {
setActiveTab('ebook');
}
return;
}
@@ -133,7 +163,8 @@ export function IndexerConfigModal({
protocol: indexer.protocol,
priority,
rssEnabled: indexer.supportsRss ? rssEnabled : false,
categories: selectedCategories,
audiobookCategories,
ebookCategories,
};
// Add protocol-specific fields
@@ -168,6 +199,12 @@ export function IndexerConfigModal({
}
};
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
return (
<Modal
isOpen={isOpen}
@@ -287,23 +324,62 @@ export function IndexerConfigModal({
)}
</div>
{/* Categories */}
{/* Categories with Tabs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Categories
</label>
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
type="button"
onClick={() => setActiveTab('audiobook')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'audiobook'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
}`}
>
AudioBook
{errors.audiobookCategories && (
<span className="ml-2 text-red-500">!</span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('ebook')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'ebook'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
}`}
>
EBook
{errors.ebookCategories && (
<span className="ml-2 text-red-500">!</span>
)}
</button>
</div>
{/* Tab Content */}
<div className="max-h-72 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<CategoryTreeView
selectedCategories={selectedCategories}
onChange={setSelectedCategories}
selectedCategories={currentCategories}
onChange={setCurrentCategories}
defaultCategories={defaultForTab}
/>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Select categories to search on this indexer. Parent selection locks all children as selected.
{activeTab === 'audiobook'
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
</p>
{errors.categories && (
{currentError && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.categories}
{currentError}
</p>
)}
</div>
@@ -28,7 +28,8 @@ interface SavedIndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Categories for audiobook searches
ebookCategories: number[]; // Categories for ebook searches
}
interface IndexerManagementProps {
@@ -11,7 +11,7 @@ import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { StatusBadge } from '@/components/requests/StatusBadge';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest } from '@/lib/hooks/useRequests';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
@@ -39,12 +39,21 @@ export function AudiobookDetailsModal({
const { user } = useAuth();
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('Request created successfully!');
const [requestError, setRequestError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
// Determine if ebook buttons should be shown
const canShowEbookButtons = isAvailable &&
ebookStatus?.ebookSourcesEnabled &&
!ebookStatus?.hasActiveEbookRequest;
useEffect(() => {
setMounted(true);
}, []);
@@ -68,6 +77,7 @@ export function AudiobookDetailsModal({
try {
await createRequest(audiobook);
setToastMessage('Request created successfully!');
setShowToast(true);
setTimeout(() => {
setShowToast(false);
@@ -103,6 +113,53 @@ export function AudiobookDetailsModal({
onRequestSuccess?.();
};
const handleFetchEbook = async () => {
if (!user) {
setRequestError('Please log in to request ebooks');
return;
}
try {
const result = await fetchEbook(asin);
revalidateEbookStatus();
if (result.needsApproval) {
setToastMessage('Ebook request submitted for approval!');
} else {
setToastMessage('Ebook search started!');
}
setShowToast(true);
setTimeout(() => {
setShowToast(false);
}, 3000);
} catch (err) {
setRequestError(err instanceof Error ? err.message : 'Failed to request ebook');
setTimeout(() => setRequestError(null), 5000);
}
};
const handleInteractiveSearchEbook = () => {
if (!user) {
setRequestError('Please log in to request ebooks');
return;
}
setShowInteractiveSearchEbook(true);
};
const handleInteractiveSearchEbookClose = () => {
setShowInteractiveSearchEbook(false);
revalidateEbookStatus();
};
const handleInteractiveSearchEbookSuccess = () => {
revalidateEbookStatus();
setToastMessage('Ebook download started!');
setShowToast(true);
setTimeout(() => {
setShowToast(false);
}, 3000);
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -419,13 +476,127 @@ export function AudiobookDetailsModal({
// Check if book is already available in library or completed status
if (isAvailable || requestStatus === 'completed') {
return (
<div className="flex-1">
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
<span className="text-base font-semibold text-green-700 dark:text-green-400">
Available in Your Library
</span>
<>
<div className="flex-1">
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
<span className="text-base font-semibold text-green-700 dark:text-green-400">
Available in Your Library
</span>
</div>
</div>
</div>
{/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */}
{canShowEbookButtons && user && (
<>
{/* Grab Ebook Button */}
<button
onClick={handleFetchEbook}
disabled={isFetchingEbook}
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
}}
title="Grab Ebook"
aria-label="Grab Ebook"
>
{isFetchingEbook ? (
<div className="animate-spin w-6 h-6 border-2 border-current border-t-transparent rounded-full" style={{ color: '#f16f19' }} />
) : (
<svg
className="w-6 h-6"
style={{ color: '#f16f19' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
)}
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Grab Ebook
</span>
</button>
{/* Interactive Search Ebook Button */}
<button
onClick={handleInteractiveSearchEbook}
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
}}
title="Search Ebook Sources"
aria-label="Search Ebook Sources"
>
<svg
className="w-6 h-6"
style={{ color: '#f16f19' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Search Ebook Sources
</span>
</button>
</>
)}
{/* Show ebook request status if one exists */}
{ebookStatus?.hasActiveEbookRequest && (
<div
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium"
style={{
borderColor: '#f16f19',
backgroundColor: 'rgba(241, 111, 25, 0.1)',
color: '#f16f19',
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval'
? 'Pending Approval'
: ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded'
? 'Available'
: 'In Progress'}
</span>
</div>
)}
</>
);
}
@@ -542,7 +713,7 @@ export function AudiobookDetailsModal({
{showToast && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<p className="text-green-800 dark:text-green-200 text-center font-medium">
Request created successfully!
{toastMessage}
</p>
</div>
)}
@@ -555,7 +726,7 @@ export function AudiobookDetailsModal({
return (
<>
{createPortal(modalContent, document.body)}
{/* Interactive Search Modal - render with higher z-index to appear above details modal */}
{/* Interactive Search Modal (Audiobook) - render with higher z-index to appear above details modal */}
{showInteractiveSearch && audiobook && createPortal(
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
<div style={{ pointerEvents: 'auto' }}>
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
</div>,
document.body
)}
{/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */}
{showInteractiveSearchEbook && audiobook && createPortal(
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
<div style={{ pointerEvents: 'auto' }}>
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearchEbook}
onClose={handleInteractiveSearchEbookClose}
onSuccess={handleInteractiveSearchEbookSuccess}
asin={asin}
audiobook={{
title: audiobook.title,
author: audiobook.author,
}}
searchMode="ebook"
/>
</div>
</div>,
document.body
)}
</>
);
}
@@ -1,6 +1,10 @@
/**
* Component: Interactive Torrent Search Modal
* Documentation: documentation/phase3/prowlarr.md
*
* Supports two search modes:
* - audiobook: Search for audiobook torrents/NZBs (default)
* - ebook: Search for ebooks from Anna's Archive + indexers
*/
'use client';
@@ -10,30 +14,43 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
import {
useInteractiveSearch,
useSelectTorrent,
useSearchTorrents,
useRequestWithTorrent,
useInteractiveSearchEbook,
useSelectEbook,
useInteractiveSearchEbookByAsin,
useSelectEbookByAsin,
} from '@/lib/hooks/useRequests';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps {
isOpen: boolean;
onClose: () => void;
requestId?: string; // Optional - only provided when called from existing request
asin?: string; // Optional - ASIN for ebook mode when no request exists
audiobook: {
title: string;
author: string;
};
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
}
export function InteractiveTorrentSearchModal({
isOpen,
onClose,
requestId,
asin,
audiobook,
fullAudiobook,
onSuccess,
searchMode = 'audiobook',
}: InteractiveTorrentSearchModalProps) {
// Hooks for existing request flow
// Hooks for existing audiobook request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
@@ -41,17 +58,36 @@ export function InteractiveTorrentSearchModal({
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
// Hooks for ebook flow (request ID-based - admin)
const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook();
const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook();
// Hooks for ebook flow (ASIN-based - user)
const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin();
const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin();
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title);
// Determine which mode we're in
const isEbookMode = searchMode === 'ebook';
const hasRequestId = !!requestId;
const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
const error = hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError);
const hasAsin = !!asin;
const useAsinMode = isEbookMode && hasAsin && !hasRequestId;
// Loading/error state based on mode
const isSearching = isEbookMode
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
const isDownloading = isEbookMode
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
const error = isEbookMode
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
: (hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError));
// Reset search title when modal opens/closes or audiobook changes
React.useEffect(() => {
@@ -72,14 +108,27 @@ export function InteractiveTorrentSearchModal({
try {
let data;
if (hasRequestId) {
// Existing flow: search by requestId with optional custom title
if (isEbookMode) {
// Ebook mode: search Anna's Archive + indexers
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
if (useAsinMode && asin) {
// ASIN-based ebook search (user flow from details modal)
data = await searchEbooksByAsin(asin, customTitle);
} else if (requestId) {
// Request ID-based ebook search (admin flow)
data = await searchEbooks(requestId, customTitle);
} else {
console.error('Ebook search requires either requestId or asin');
return;
}
} else if (hasRequestId) {
// Existing audiobook flow: search by requestId with optional custom title
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else {
// New flow: search by custom title + original author + optional ASIN for size scoring
const asin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
const audiobookAsin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
}
setResults(data || []);
} catch (err) {
@@ -102,11 +151,22 @@ export function InteractiveTorrentSearchModal({
if (!confirmTorrent) return;
try {
if (hasRequestId) {
// Existing flow: select torrent for existing request
if (isEbookMode) {
// Ebook flow
if (useAsinMode && asin) {
// ASIN-based ebook selection (user flow from details modal)
await selectEbookByAsin(asin, confirmTorrent);
} else if (requestId) {
// Request ID-based ebook selection (admin flow)
await selectEbook(requestId, confirmTorrent);
} else {
throw new Error('Request ID or ASIN required for ebook selection');
}
} else if (hasRequestId) {
// Existing audiobook flow: select torrent for existing request
await selectTorrent(requestId, confirmTorrent);
} else {
// New flow: create request with torrent
// New audiobook flow: create request with torrent
if (!fullAudiobook) {
throw new Error('Audiobook data required to create request');
}
@@ -120,7 +180,7 @@ export function InteractiveTorrentSearchModal({
// Request list will auto-refresh via SWR
} catch (err) {
// Error already handled by hook
console.error('Failed to download torrent:', err);
console.error('Failed to download:', err);
setConfirmTorrent(null);
}
};
@@ -138,14 +198,26 @@ export function InteractiveTorrentSearchModal({
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
// UI text based on mode
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
const resultCountText = (count: number) =>
isEbookMode
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
<div className="space-y-4">
{/* Search customization - editable for ALL modes */}
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Title
{searchLabel}
</label>
<div className="flex gap-2">
<input
@@ -153,7 +225,7 @@ export function InteractiveTorrentSearchModal({
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyPress={handleSearchKeyPress}
placeholder="Enter book title to search..."
placeholder={searchPlaceholder}
disabled={isSearching}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
/>
@@ -180,14 +252,14 @@ export function InteractiveTorrentSearchModal({
{isSearching && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Searching for torrents...</span>
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
</div>
)}
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
@@ -220,7 +292,7 @@ export function InteractiveTorrentSearchModal({
Seeds
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
Indexer
{isEbookMode ? 'Source' : 'Indexer'}
</th>
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
Action
@@ -246,21 +318,30 @@ export function InteractiveTorrentSearchModal({
</a>
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{/* Anna's Archive badge for ebook mode */}
{isEbookMode && result.source === 'annas_archive' && (
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
Anna's Archive
</span>
)}
{result.format && (
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
{result.format}
</span>
)}
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
{formatSize(result.size)}
</span>
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
</span>
{/* Hide seeds badge for Anna's Archive results */}
{!(isEbookMode && result.source === 'annas_archive') && (
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
</span>
)}
</div>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
{formatSize(result.size)}
{result.size > 0 ? formatSize(result.size) : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
@@ -271,15 +352,23 @@ export function InteractiveTorrentSearchModal({
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-gray-400">N/A</span>
) : (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
{result.indexer}
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
) : (
result.indexer
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
<Button
@@ -303,7 +392,7 @@ export function InteractiveTorrentSearchModal({
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
{resultCountText(results.length)}
</p>
<Button onClick={performSearch} variant="outline" size="sm">
Refresh Results
@@ -318,7 +407,7 @@ export function InteractiveTorrentSearchModal({
isOpen={!!confirmTorrent}
onClose={() => setConfirmTorrent(null)}
onConfirm={handleConfirmDownload}
title="Download Torrent"
title={confirmTitle}
message={`Download "${confirmTorrent?.title}"?`}
confirmText="Download"
isLoading={isDownloading}
+43 -16
View File
@@ -16,6 +16,7 @@ import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
interface RequestCardProps {
request: {
id: string;
type?: 'audiobook' | 'ebook';
status: string;
progress: number;
errorMessage?: string;
@@ -38,10 +39,14 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const [showError, setShowError] = React.useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
// Ebook requests don't support interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const handleCancel = async () => {
if (window.confirm('Are you sure you want to cancel this request?')) {
@@ -100,19 +105,30 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{isEbook ? (
<svg
className="w-12 h-12"
style={{ color: '#f16f19' }}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
</svg>
) : (
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
)}
</div>
)}
</div>
@@ -130,9 +146,20 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
</p>
</div>
{/* Status Badge */}
<div className="flex items-center gap-2">
{/* Status Badge and Type Badge */}
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={request.status} progress={request.progress} />
{isEbook && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
{isActive && request.progress > 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<div className="animate-pulse w-2 h-2 bg-blue-500 rounded-full"></div>
+3 -1
View File
@@ -927,7 +927,7 @@ export async function isInLibrary(
}
/**
* Check if book has already been requested
* Check if book has already been requested (audiobook request)
* @param userId - User ID
* @param asin - Audible ASIN
* @returns true if book is already requested
@@ -939,6 +939,8 @@ export async function isAlreadyRequested(
const request = await prisma.request.findFirst({
where: {
userId,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
audiobook: {
audibleAsin: asin,
},
+244
View File
@@ -397,3 +397,247 @@ export function useRequestWithTorrent() {
return { requestWithTorrent, isLoading, error };
}
export function useInteractiveSearchEbook() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchEbooks = async (requestId: string, customTitle?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to search for ebooks');
}
return data.results || [];
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { searchEbooks, isLoading, error };
}
export function useSelectEbook() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectEbook = async (requestId: string, ebook: any) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/select-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ebook }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to download ebook');
}
// Revalidate requests
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { selectEbook, isLoading, error };
}
// ==================== ASIN-based Ebook Hooks ====================
// These hooks are used for requesting ebooks from the audiobook details modal
// where we only have an ASIN, not an existing request ID
export interface EbookStatus {
ebookSourcesEnabled: boolean;
hasActiveEbookRequest: boolean;
existingEbookStatus: string | null;
existingEbookRequestId: string | null;
}
export function useEbookStatus(asin: string | null) {
const { accessToken } = useAuth();
const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/ebook-status` : null;
const { data, error, isLoading, mutate: revalidate } = useSWR<EbookStatus>(
endpoint,
fetcher,
{
refreshInterval: 10000, // Refresh every 10 seconds
}
);
return {
ebookStatus: data || null,
isLoading,
error,
revalidate,
};
}
export function useFetchEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEbook = async (asin: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/fetch-ebook`, {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to request ebook');
}
// Revalidate requests and ebook status
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { fetchEbook, isLoading, error };
}
export function useInteractiveSearchEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchEbooks = async (asin: string, customTitle?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/interactive-search-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to search for ebooks');
}
return data.results || [];
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { searchEbooks, isLoading, error };
}
export function useSelectEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectEbook = async (asin: string, ebook: any) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/select-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ebook }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to download ebook');
}
// Revalidate requests and ebook status
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { selectEbook, isLoading, error };
}
@@ -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
}
@@ -2,7 +2,7 @@
* Component: Monitor RSS Feeds Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Monitors RSS feeds for new audiobook releases and matches against missing requests
* Monitors RSS feeds for new releases and matches against missing requests (audiobooks and ebooks)
*/
import { prisma } from '../db';
@@ -57,7 +57,8 @@ 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 requests awaiting search (audiobooks and ebooks)
// Both types can be matched against RSS torrent feeds
const missingRequests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
@@ -73,7 +74,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
return { success: true, message: 'No missing requests', matched: 0 };
}
// Match RSS results against missing audiobooks
// Match RSS results against missing requests
let matched = 0;
const jobQueue = getJobQueueService();
@@ -94,16 +95,27 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
if (hasAuthor && titleMatchCount >= 2) {
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
// Trigger search job to process this request
// Trigger appropriate search job based on request type
try {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered search job for request ${request.id}`);
if (request.type === 'ebook') {
await jobQueue.addSearchEbookJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered ebook search job for request ${request.id}`);
} else {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered audiobook search job for request ${request.id}`);
}
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
+443 -19
View File
@@ -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 },
@@ -45,36 +67,53 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
logger.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
// Fetch year from multiple sources (priority order)
// Fetch missing metadata from AudibleCache if needed
// Year and narrator can both be part of path templates
let year = audiobook.year || undefined;
logger.info(`Initial year from audiobook record: ${year || 'null'}`);
let narrator = audiobook.narrator || undefined;
if (!year && audiobook.audibleAsin) {
logger.info(`No year in audiobook record, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`);
logger.info(`Initial metadata from audiobook record: year=${year || 'null'}, narrator=${narrator || 'null'}`);
// Try to enrich missing metadata from AudibleCache
if (audiobook.audibleAsin && (!year || !narrator)) {
logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`);
// Try AudibleCache (for popular/new releases)
const audibleCache = await prisma.audibleCache.findUnique({
where: { asin: audiobook.audibleAsin },
select: { releaseDate: true },
select: { releaseDate: true, narrator: true },
});
if (audibleCache?.releaseDate) {
logger.info(`Found AudibleCache entry with releaseDate: ${audibleCache.releaseDate}`);
year = new Date(audibleCache.releaseDate).getFullYear();
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
if (audibleCache) {
const updates: { year?: number; narrator?: string } = {};
// Update audiobook record with year for future use
await prisma.audiobook.update({
where: { id: audiobookId },
data: { year },
});
logger.info(`Updated audiobook record with year ${year}`);
// Extract year from releaseDate if missing
if (!year && audibleCache.releaseDate) {
year = new Date(audibleCache.releaseDate).getFullYear();
updates.year = year;
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
}
// Get narrator if missing
if (!narrator && audibleCache.narrator) {
narrator = audibleCache.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from AudibleCache`);
}
// Update audiobook record with enriched data for future use
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated audiobook record with enriched metadata`);
}
} else {
logger.info(`No year found in AudibleCache for ASIN ${audiobook.audibleAsin}`);
logger.info(`No AudibleCache entry found for ASIN ${audiobook.audibleAsin}`);
}
}
logger.info(`Final year value for path organization: ${year || 'null (year will be omitted from path)'}`)
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
// Get file organizer (reads media_dir from database config)
const organizer = await getFileOrganizer();
@@ -91,7 +130,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
{
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
narrator,
coverArtUrl: audiobook.coverArtUrl || undefined,
asin: audiobook.audibleAsin || undefined,
year,
@@ -149,6 +188,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();
@@ -303,8 +346,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
// Check if this is a retryable error (transient filesystem issues or no files found)
// These errors may resolve on retry (e.g., files still being extracted, permissions being set)
const isRetryableError =
errorMessage.includes('No audiobook files found') ||
errorMessage.includes('No ebook files found') || // Ebook equivalent of above
errorMessage.includes('ENOENT') || // File/directory not found
errorMessage.includes('no such file or directory') ||
errorMessage.includes('EACCES') || // Permission denied (might be temporary)
@@ -433,3 +478,382 @@ 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}`);
// Fetch missing metadata from AudibleCache (same pattern as audiobooks)
// Year, narrator, series, seriesPart can all be part of path templates
let year = book.year || undefined;
let narrator = book.narrator || undefined;
let series = book.series || undefined;
let seriesPart = book.seriesPart || undefined;
logger.info(`Initial metadata from book record: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}`);
// Try to enrich missing metadata from AudibleCache
if (book.audibleAsin && (!year || !narrator)) {
logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${book.audibleAsin}`);
const audibleCache = await prisma.audibleCache.findUnique({
where: { asin: book.audibleAsin },
select: { releaseDate: true, narrator: true, },
});
if (audibleCache) {
const updates: { year?: number; narrator?: string } = {};
// Extract year from releaseDate if missing
if (!year && audibleCache.releaseDate) {
year = new Date(audibleCache.releaseDate).getFullYear();
updates.year = year;
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
}
// Get narrator if missing
if (!narrator && audibleCache.narrator) {
narrator = audibleCache.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from AudibleCache`);
}
// Update book record with enriched data for future use
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated book record with enriched metadata`);
}
} else {
logger.info(`No AudibleCache entry found for ASIN ${book.audibleAsin}`);
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
// Check if this is an indexer download (needs to keep source for seeding)
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
// 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)
// Pass all metadata that could be used in path templates (same as audiobooks)
const result = await organizer.organizeEbook(
downloadPath,
{
title: book.title,
author: book.author,
narrator,
asin: book.audibleAsin || undefined,
year,
series,
seriesPart,
},
template,
jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined,
isIndexerDownload
);
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})`);
}
// Cleanup Usenet downloads if configured (same logic as audiobooks)
try {
logger.info('Checking if cleanup is needed for ebook download');
// downloadHistory was already fetched earlier in this function
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
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
*
* Supports two ebook sources:
* - Anna's Archive (ebook_annas_archive_enabled) - Currently implemented
* - Indexer Search (ebook_indexer_search_enabled) - Future feature, gracefully skipped
*/
async function createEbookRequestIfEnabled(
parentRequestId: string,
audiobook: { id: string; title: string; author: string; audibleAsin: string | null },
userId: string,
targetPath: string,
logger: RMABLogger
): Promise<void> {
try {
const configService = getConfigService();
// Check if auto-grab is enabled (default: true for backward compatibility)
const autoGrabEnabled = await configService.get('ebook_auto_grab_enabled');
if (autoGrabEnabled === 'false') {
logger.info('Ebook auto-grab disabled, skipping automatic ebook request creation');
return;
}
// Check which ebook sources are enabled
const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled');
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
// Legacy migration: check old key if new keys don't exist
const legacyEnabled = await configService.get('ebook_sidecar_enabled');
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true' ||
(annasArchiveEnabled === null && legacyEnabled === 'true');
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
// If no sources are enabled, skip ebook creation
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
logger.info('Ebook downloads disabled (no sources enabled), skipping ebook request creation');
return;
}
// At least one source is enabled - proceed with ebook request creation
// 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 (Anna's Archive)
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,
},
@@ -43,9 +43,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
return { enabled: false, remotePath: '', localPath: '' };
};
// 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,7 +21,7 @@ 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 requests (audiobook or ebook) in awaiting_search status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
@@ -43,20 +43,33 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
};
}
// Trigger search job for each request
// Trigger appropriate search job for each request based on type
const jobQueue = getJobQueueService();
let triggered = 0;
for (const request of requests) {
try {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
if (request.type === 'ebook') {
// Ebook requests use ebook search (Anna's Archive, etc.)
await jobQueue.addSearchEbookJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
} else {
// Audiobook requests use indexer search (Prowlarr)
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
}
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
+3 -1
View File
@@ -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,504 @@
/**
* Component: Search Ebook Job Processor
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Searches for ebook downloads using multiple sources:
* 1. Anna's Archive (if enabled) - direct HTTP downloads
* 2. Indexer Search (if enabled) - via Prowlarr with ebook categories
*/
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 { getProwlarrService } from '../integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
// Import ebook scraper functions for Anna's Archive
import {
searchByAsin,
searchByTitle,
getSlowDownloadLinks,
} from '../services/ebook-scraper';
/**
* Process search ebook job
* Searches Anna's Archive first (if enabled), then falls back to indexer search (if enabled)
*/
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 annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled') === 'true';
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled') === 'true';
logger.info(`Sources: Anna's Archive=${annasArchiveEnabled}, Indexer Search=${indexerSearchEnabled}`);
logger.info(`Preferred format: ${preferredFormat}`);
// Track whether we found a result
let annasArchiveResult: EbookSearchResult | null = null;
let indexerResult: RankedEbookTorrent | null = null;
// ========== STEP 1: Try Anna's Archive (if enabled) ==========
if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger);
if (annasArchiveResult) {
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`);
} else {
logger.info(`No results from Anna's Archive`);
}
}
// ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger);
if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
} else {
logger.info(`No results from indexer search`);
}
}
// ========== STEP 3: Handle Results ==========
if (!annasArchiveResult && !indexerResult) {
// No results found from any source
const enabledSources = [];
if (annasArchiveEnabled) enabledSources.push("Anna's Archive");
if (indexerSearchEnabled) enabledSources.push("Indexer Search");
const message = enabledSources.length > 0
? `No ebook found on ${enabledSources.join(' or ')}. Will retry automatically.`
: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.';
logger.warn(`No ebook found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: message,
lastSearchAt: new Date(),
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No ebook found, queued for re-search',
requestId,
};
}
// ========== STEP 4: Route to Appropriate Download ==========
if (annasArchiveResult) {
// Anna's Archive result → Direct download
return await handleAnnasArchiveDownload(requestId, audiobook, annasArchiveResult, preferredFormat, logger);
} else if (indexerResult) {
// Indexer result → Torrent/NZB download (reuse audiobook processor)
return await handleIndexerDownload(requestId, audiobook, indexerResult, preferredFormat, logger);
}
// This should never be reached
throw new Error('Unexpected state: no result to process');
} 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;
}
}
/**
* Search Anna's Archive for ebook
*/
async function searchAnnasArchive(
audiobook: { title: string; author: string; asin?: string },
preferredFormat: string,
logger: RMABLogger
): Promise<EbookSearchResult | null> {
const configService = getConfigService();
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';
// Try ASIN search first (exact match - best)
if (audiobook.asin) {
logger.info(`Searching Anna's Archive 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 ASIN results, trying title + author...`);
}
}
// Fallback to title + author search
if (!md5) {
logger.info(`Searching Anna's Archive 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) {
return null;
}
// Get slow download links
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, logger, flaresolverrUrl);
if (slowLinks.length === 0) {
logger.warn(`Found MD5 ${md5} but no download links available`);
return null;
}
logger.info(`Found ${slowLinks.length} download link(s) for MD5 ${md5}`);
return {
md5,
title: audiobook.title,
author: audiobook.author,
format: preferredFormat,
downloadUrls: slowLinks,
source: 'annas_archive',
score: searchMethod === 'asin' ? 100 : 80,
};
}
/**
* Search indexers for ebook torrents/NZBs
*/
async function searchIndexers(
requestId: string,
audiobook: { title: string; author: string },
preferredFormat: string,
logger: RMABLogger
): Promise<RankedEbookTorrent | null> {
const configService = getConfigService();
// Get enabled indexers from configuration
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
logger.warn('No indexers configured');
return null;
}
const indexersConfig = JSON.parse(indexersConfigStr);
if (indexersConfig.length === 0) {
logger.warn('No indexers enabled');
return null;
}
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
const indexerPriorities = new Map<number, number>(
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
);
// Get flag configurations
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by their EBOOK category configuration
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
logger.info(`Group ${index + 1}: ${getGroupDescription(group)}`);
});
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Build search query (title only - cast wide net, let ranking filter)
const searchQuery = audiobook.title;
logger.info(`Searching for: "${searchQuery}"`);
// Search Prowlarr for each group and combine results
const allResults = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.search(searchQuery, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 0, // Ebooks may have fewer seeders
maxResults: 100,
});
logger.info(`Group ${i + 1} returned ${groupResults.length} results`);
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other groups even if one fails
}
}
logger.info(`Found ${allResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (allResults.length === 0) {
return null;
}
// Log filter info (ebooks > 20MB will be filtered)
const preFilterCount = allResults.length;
const aboveThreshold = allResults.filter(r => (r.size / (1024 * 1024)) > 20);
if (aboveThreshold.length > 0) {
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
}
// Rank results with ebook-specific scoring
// This filters out > 20MB and uses inverted size scoring
const rankedResults = rankEbookTorrents(allResults, {
title: audiobook.title,
author: audiobook.author,
preferredFormat,
}, {
indexerPriorities,
flagConfigs,
requireAuthor: true, // Automatic mode - prevent wrong authors
});
// Log filter results
const postFilterCount = rankedResults.length;
if (postFilterCount < preFilterCount) {
logger.info(`Filtered out ${preFilterCount - postFilterCount} results > 20 MB`);
}
// Dual threshold filtering (same as audiobooks)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
logger.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
logger.info(`${disqualifiedByNegativeBonus} ebooks disqualified by negative flag bonuses`);
}
if (filteredResults.length === 0) {
logger.warn(`No quality matches found (all below 50/100)`);
return null;
}
// Select best result
const bestResult = filteredResults[0];
// Log top 3 results with detailed breakdown
const top3 = filteredResults.slice(0, 3);
logger.info(`==================== EBOOK RANKING DEBUG ====================`);
logger.info(`Requested Title: "${audiobook.title}"`);
logger.info(`Requested Author: "${audiobook.author}"`);
logger.info(`Preferred Format: ${preferredFormat}`);
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
logger.info(`--------------------------------------------------------------`);
for (let i = 0; i < top3.length; i++) {
const result = top3[i];
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
logger.info(`${i + 1}. "${result.title}"`);
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
logger.info(``);
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`);
logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`);
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
logger.info(``);
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
for (const mod of result.bonusModifiers) {
logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
}
}
logger.info(``);
logger.info(` Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) {
logger.info(` Notes: ${result.breakdown.notes.join(', ')}`);
}
if (i < top3.length - 1) {
logger.info(`--------------------------------------------------------------`);
}
}
logger.info(`==============================================================`);
logger.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`);
return bestResult;
}
/**
* Handle Anna's Archive download (direct HTTP)
*/
async function handleAnnasArchiveDownload(
requestId: string,
audiobook: { title: string; author: string },
result: EbookSearchResult,
preferredFormat: string,
logger: RMABLogger
): Promise<any> {
logger.info(`==================== EBOOK SEARCH RESULT ====================`);
logger.info(`Source: Anna's Archive`);
logger.info(`Title: "${audiobook.title}"`);
logger.info(`Author: "${audiobook.author}"`);
logger.info(`Format: ${preferredFormat}`);
logger.info(`MD5: ${result.md5}`);
logger.info(`Download Links: ${result.downloadUrls.length}`);
logger.info(`Score: ${result.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: result.score,
selected: true,
downloadClient: 'direct', // Direct HTTP download
downloadStatus: 'queued',
},
});
// Trigger direct download job
const jobQueue = getJobQueueService();
await jobQueue.addStartDirectDownloadJob(
requestId,
downloadHistory.id,
result.downloadUrls[0], // Start with first link
`${audiobook.title} - ${audiobook.author}.${preferredFormat}`,
undefined // Size unknown
);
// Store all download URLs for retry purposes
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: {
torrentUrl: JSON.stringify(result.downloadUrls),
},
});
return {
success: true,
message: `Found ebook via Anna's Archive, starting download`,
requestId,
source: 'annas_archive',
searchResult: {
md5: result.md5,
format: result.format,
score: result.score,
downloadLinksCount: result.downloadUrls.length,
},
};
}
/**
* Handle indexer download (torrent/NZB via download-torrent processor)
*/
async function handleIndexerDownload(
requestId: string,
audiobook: { title: string; author: string },
result: RankedEbookTorrent,
preferredFormat: string,
logger: RMABLogger
): Promise<any> {
logger.info(`==================== EBOOK SEARCH RESULT ====================`);
logger.info(`Source: Indexer (${result.indexer})`);
logger.info(`Title: "${audiobook.title}"`);
logger.info(`Author: "${audiobook.author}"`);
logger.info(`Torrent: "${result.title}"`);
logger.info(`Size: ${(result.size / (1024 * 1024)).toFixed(1)} MB`);
logger.info(`Seeders: ${result.seeders !== undefined ? result.seeders : 'N/A'}`);
logger.info(`Final Score: ${result.finalScore.toFixed(1)}/100`);
logger.info(`==============================================================`);
// Trigger download job using the SAME processor as audiobooks
// The download-torrent processor is already generic and handles both torrent and NZB
const jobQueue = getJobQueueService();
// Fetch the request to get the parent audiobook ID for the download job
const request = await prisma.request.findUnique({
where: { id: requestId },
include: { parentRequest: true },
});
if (!request) {
throw new Error(`Request ${requestId} not found`);
}
// Use the parent audiobook's ID for the download job, or fall back to request ID
const audiobookId = request.parentRequest?.id || request.id;
await jobQueue.addDownloadJob(requestId, {
id: audiobookId,
title: audiobook.title,
author: audiobook.author,
}, result);
return {
success: true,
message: `Found ebook via indexer search, starting download`,
requestId,
source: 'prowlarr',
resultsCount: 1,
selectedTorrent: {
title: result.title,
score: result.score,
finalScore: result.finalScore,
seeders: result.seeders || 0,
size: result.size,
},
};
}
+9 -5
View File
@@ -304,8 +304,9 @@ export async function downloadEbook(
/**
* Step 1: Search Anna's Archive by ASIN and extract MD5 hash
* Exported for use by search-ebook processor
*/
async function searchByAsin(
export async function searchByAsin(
asin: string,
format: string,
baseUrl: string,
@@ -394,8 +395,9 @@ async function searchByAsin(
/**
* Search Anna's Archive by title and author (fallback method)
* Exported for use by search-ebook processor
*/
async function searchByTitle(
export async function searchByTitle(
title: string,
author: string,
format: string,
@@ -486,8 +488,9 @@ async function searchByTitle(
/**
* Step 3: Get slow download links from MD5 page (no waitlist only)
* Exported for use by search-ebook processor
*/
async function getSlowDownloadLinks(
export async function getSlowDownloadLinks(
md5: string,
baseUrl: string,
logger?: RMABLogger,
@@ -561,7 +564,7 @@ async function getSlowDownloadLinks(
}
}
interface ExtractedDownload {
export interface ExtractedDownload {
url: string;
format: string;
}
@@ -570,8 +573,9 @@ interface ExtractedDownload {
* Step 4: Extract actual download URL from slow download page
* IMPORTANT: Supports dynamic file formats (not hardcoded to .epub)
* Returns both URL and detected format
* Exported for use by direct-download processor
*/
async function extractDownloadUrl(
export async function extractDownloadUrl(
slowDownloadUrl: string,
baseUrl: string,
format: string,
+137 -1
View File
@@ -24,7 +24,11 @@ export type JobType =
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'send_notification';
| 'send_notification'
// Ebook-specific job types
| 'search_ebook'
| 'start_direct_download'
| 'monitor_direct_download';
export interface JobPayload {
jobId?: string; // Database job ID (added automatically by addJob)
@@ -95,6 +99,45 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
// Ebook-specific payload interfaces
export interface SearchEbookPayload extends JobPayload {
requestId: string;
audiobook: {
id: string;
title: string;
author: string;
asin?: string; // ASIN for Anna's Archive search (best match)
};
preferredFormat?: string; // epub, pdf, mobi, azw3 (default: from config)
}
export interface EbookSearchResult {
md5: string;
title: string;
author: string;
format: string;
fileSize?: number;
downloadUrls: string[]; // Slow download URLs from Anna's Archive
source: 'annas_archive'; // For future indexer support
score: number; // Ranking score (for future multi-source ranking)
}
export interface StartDirectDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadUrl: string;
targetFilename: string;
expectedSize?: number;
}
export interface MonitorDirectDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadId: string; // Internal tracking ID
targetPath: string; // Full path to the downloading file
expectedSize?: number;
}
export interface SendNotificationPayload extends JobPayload {
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
@@ -301,6 +344,22 @@ export class JobQueueService {
const { processSendNotification } = await import('../processors/send-notification.processor');
return await processSendNotification(job.data);
});
// Ebook-specific processors
this.queue.process('search_ebook', 3, async (job: BullJob<SearchEbookPayload>) => {
const { processSearchEbook } = await import('../processors/search-ebook.processor');
return await processSearchEbook(job.data);
});
this.queue.process('start_direct_download', 3, async (job: BullJob<StartDirectDownloadPayload>) => {
const { processStartDirectDownload } = await import('../processors/direct-download.processor');
return await processStartDirectDownload(job.data);
});
this.queue.process('monitor_direct_download', 5, async (job: BullJob<MonitorDirectDownloadPayload>) => {
const { processMonitorDirectDownload } = await import('../processors/direct-download.processor');
return await processMonitorDirectDownload(job.data);
});
}
/**
@@ -635,6 +694,83 @@ export class JobQueueService {
);
}
// =========================================================================
// EBOOK-SPECIFIC JOB METHODS
// =========================================================================
/**
* Add search ebook job (Anna's Archive search)
*/
async addSearchEbookJob(
requestId: string,
audiobook: { id: string; title: string; author: string; asin?: string },
preferredFormat?: string
): Promise<string> {
return await this.addJob(
'search_ebook',
{
requestId,
audiobook,
preferredFormat,
} as SearchEbookPayload,
{
priority: 10, // High priority for user-initiated requests
}
);
}
/**
* Add start direct download job (HTTP download for ebooks)
*/
async addStartDirectDownloadJob(
requestId: string,
downloadHistoryId: string,
downloadUrl: string,
targetFilename: string,
expectedSize?: number
): Promise<string> {
return await this.addJob(
'start_direct_download',
{
requestId,
downloadHistoryId,
downloadUrl,
targetFilename,
expectedSize,
} as StartDirectDownloadPayload,
{
priority: 9, // High priority - download selected ebook
}
);
}
/**
* Add monitor direct download job (tracks HTTP download progress)
*/
async addMonitorDirectDownloadJob(
requestId: string,
downloadHistoryId: string,
downloadId: string,
targetPath: string,
expectedSize?: number,
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
'monitor_direct_download',
{
requestId,
downloadHistoryId,
downloadId,
targetPath,
expectedSize,
} as MonitorDirectDownloadPayload,
{
priority: 5, // Medium priority
delay: delaySeconds * 1000,
}
);
}
/**
* Get job by ID
*/
+199 -119
View File
@@ -26,7 +26,7 @@ export interface DeleteRequestResult {
/**
* Soft delete a request with intelligent cleanup of media files and torrents
*
* Logic:
* Logic (audiobook requests):
* 1. Check if request exists and is not already deleted
* 2. For each download:
* - If unlimited seeding (0): Log and keep seeding, no monitoring
@@ -34,7 +34,15 @@ export interface DeleteRequestResult {
* - If seeding requirement met: Delete torrent + files
* - If still seeding: Keep in qBittorrent for cleanup job
* 3. Delete media files (title folder only)
* 4. Soft delete request (set deletedAt, deletedBy)
* 4. Delete from backend library (Plex/ABS)
* 5. Clear audiobook availability linkage
* 6. Soft delete request (set deletedAt, deletedBy)
*
* Logic (ebook requests):
* 1. Check if request exists and is not already deleted
* 2. Delete ebook files only (leave audiobook files intact)
* 3. Soft delete request (set deletedAt, deletedBy)
* Note: No backend library deletion or audiobook linkage clearing for ebooks
*/
export async function deleteRequest(
requestId: string,
@@ -57,6 +65,7 @@ export async function deleteRequest(
audibleAsin: true,
plexGuid: true,
absItemId: true,
fileFormat: true,
},
},
downloadHistory: {
@@ -71,6 +80,10 @@ export async function deleteRequest(
},
});
// Determine request type (default to audiobook for backward compatibility)
const requestType = (request as any)?.type || 'audiobook';
const isEbook = requestType === 'ebook';
if (!request) {
return {
success: false,
@@ -87,10 +100,11 @@ export async function deleteRequest(
let torrentsKeptSeeding = 0;
let torrentsKeptUnlimited = 0;
// 2. Handle downloads & seeding
// 2. Handle downloads & seeding (skip for ebooks - they use direct HTTP downloads)
const downloadHistory = request.downloadHistory[0];
const skipTorrentHandling = isEbook; // Ebooks use direct downloads, not torrents/NZBs
if (downloadHistory && downloadHistory.indexerName) {
if (!skipTorrentHandling && downloadHistory && downloadHistory.indexerName) {
try {
// Get indexer seeding configuration
const { getConfigService } = await import('./config.service');
@@ -186,7 +200,9 @@ export async function deleteRequest(
}
}
// 3. Delete media files (title folder only)
// 3. Delete media files
// For audiobooks: delete entire title folder
// For ebooks: delete only ebook files (leave audiobook files intact)
let filesDeleted = false;
try {
const { getConfigService } = await import('./config.service');
@@ -219,15 +235,34 @@ export async function deleteRequest(
}
);
// Check if folder exists and delete it
// Check if folder exists
try {
await fs.access(titleFolderPath);
// Delete the title folder (not the author folder)
await fs.rm(titleFolderPath, { recursive: true, force: true });
if (isEbook) {
// For ebooks: only delete ebook files, leave audiobook files intact
const ebookExtensions = ['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.fb2', '.cbz', '.cbr'];
const files = await fs.readdir(titleFolderPath);
logger.info(`Deleted media directory: ${titleFolderPath}`);
filesDeleted = true;
let deletedCount = 0;
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (ebookExtensions.includes(ext)) {
const filePath = path.join(titleFolderPath, file);
await fs.unlink(filePath);
logger.info(`Deleted ebook file: ${file}`);
deletedCount++;
}
}
filesDeleted = deletedCount > 0;
logger.info(`Deleted ${deletedCount} ebook file(s) from: ${titleFolderPath}`);
} else {
// For audiobooks: delete the entire title folder
await fs.rm(titleFolderPath, { recursive: true, force: true });
logger.info(`Deleted media directory: ${titleFolderPath}`);
filesDeleted = true;
}
} catch (accessError) {
// Folder doesn't exist - that's okay
logger.info(`Media directory not found: ${titleFolderPath}`);
@@ -242,143 +277,188 @@ export async function deleteRequest(
}
// 4. Delete from plex_library table and clear audiobook availability
// Skip for ebooks - audiobook files and library entry should remain intact
// This ensures the book immediately shows as NOT available when searching
try {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
if (!isEbook) {
try {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Delete from library backend (ABS or Plex)
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
// Audiobookshelf: delete the library item from ABS
try {
const { deleteABSItem } = await import('../services/audiobookshelf/api');
await deleteABSItem(request.audiobook.absItemId);
logger.info(
`Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"`
);
} catch (absError) {
logger.error(
`Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`,
{ error: absError instanceof Error ? absError.message : String(absError) }
);
// Continue with deletion even if ABS deletion fails
// Delete from library backend (ABS or Plex)
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
// Audiobookshelf: delete the library item from ABS
try {
const { deleteABSItem } = await import('../services/audiobookshelf/api');
await deleteABSItem(request.audiobook.absItemId);
logger.info(
`Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"`
);
} catch (absError) {
logger.error(
`Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`,
{ error: absError instanceof Error ? absError.message : String(absError) }
);
// Continue with deletion even if ABS deletion fails
}
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
// Plex: delete the library item from Plex by ratingKey
try {
// Query plex_library table to get the ratingKey
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: request.audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
const ratingKey = plexLibraryRecord.plexRatingKey;
// Get Plex config
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
logger.info(
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
);
} else {
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
}
} else {
logger.warn(
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
);
}
} catch (plexError) {
logger.error(
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
);
// Continue with deletion even if Plex deletion fails
}
}
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
// Plex: delete the library item from Plex by ratingKey
// Delete ALL plex_library records matching this audiobook's title and author
// This handles cases where there might be duplicate library records
// and ensures the book doesn't show as "In Your Library" during searches
try {
// Query plex_library table to get the ratingKey
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: request.audiobook.plexGuid },
select: { plexRatingKey: true },
// Find all matching library records (by title/author fuzzy match)
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
where: {
title: {
contains: request.audiobook.title.substring(0, 20),
mode: 'insensitive',
},
},
});
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
const ratingKey = plexLibraryRecord.plexRatingKey;
// Filter to exact matches (case-insensitive title and author)
const exactMatches = matchingLibraryRecords.filter((record) => {
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
return titleMatch && authorMatch;
});
// Get Plex config
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (exactMatches.length > 0) {
// Delete all exact matches
const deletePromises = exactMatches.map((record) =>
prisma.plexLibrary.delete({ where: { id: record.id } })
);
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
logger.info(
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
);
} else {
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
}
await Promise.all(deletePromises);
logger.info(
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
);
} else {
logger.warn(
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
logger.info(
`No plex_library records found for "${request.audiobook.title}"`
);
}
} catch (plexError) {
} catch (libError) {
logger.error(
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
`Error deleting plex_library records`,
{ error: libError instanceof Error ? libError.message : String(libError) }
);
// Continue with deletion even if Plex deletion fails
// Continue with deletion even if library cleanup fails
}
}
// Delete ALL plex_library records matching this audiobook's title and author
// This handles cases where there might be duplicate library records
// and ensures the book doesn't show as "In Your Library" during searches
// Clear audiobook record linkage
const updateData: any = {
status: 'requested', // Reset to requested state
updatedAt: new Date(),
};
// Clear library linkage based on backend mode
if (backendMode === 'audiobookshelf') {
updateData.absItemId = null;
} else {
updateData.plexGuid = null;
}
await prisma.audiobook.update({
where: { id: request.audiobook.id },
data: updateData,
});
logger.info(
`Cleared availability status for audiobook ${request.audiobook.id}`
);
} catch (error) {
logger.error(
`Error clearing audiobook status`,
{ error: error instanceof Error ? error.message : String(error) }
);
// Continue with deletion even if this fails
}
} else {
logger.info(`Skipping backend library deletion for ebook request ${requestId}`);
}
// 5. Delete child requests (ebook requests linked to this audiobook request)
if (!isEbook) {
try {
// Find all matching library records (by title/author fuzzy match)
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
const childRequests = await prisma.request.findMany({
where: {
title: {
contains: request.audiobook.title.substring(0, 20),
mode: 'insensitive',
},
parentRequestId: requestId,
deletedAt: null,
},
select: {
id: true,
type: true,
},
});
// Filter to exact matches (case-insensitive title and author)
const exactMatches = matchingLibraryRecords.filter((record) => {
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
return titleMatch && authorMatch;
});
if (childRequests.length > 0) {
logger.info(`Found ${childRequests.length} child request(s) to delete`);
if (exactMatches.length > 0) {
// Delete all exact matches
const deletePromises = exactMatches.map((record) =>
prisma.plexLibrary.delete({ where: { id: record.id } })
);
// Soft delete all child requests
await prisma.request.updateMany({
where: {
parentRequestId: requestId,
deletedAt: null,
},
data: {
deletedAt: new Date(),
deletedBy: adminUserId,
},
});
await Promise.all(deletePromises);
logger.info(
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
);
} else {
logger.info(
`No plex_library records found for "${request.audiobook.title}"`
);
logger.info(`Soft-deleted ${childRequests.length} child request(s)`);
}
} catch (libError) {
} catch (error) {
logger.error(
`Error deleting plex_library records`,
{ error: libError instanceof Error ? libError.message : String(libError) }
`Error deleting child requests for ${requestId}`,
{ error: error instanceof Error ? error.message : String(error) }
);
// Continue with deletion even if library cleanup fails
// Continue with parent deletion even if child deletion fails
}
// Clear audiobook record linkage
const updateData: any = {
status: 'requested', // Reset to requested state
updatedAt: new Date(),
};
// Clear library linkage based on backend mode
if (backendMode === 'audiobookshelf') {
updateData.absItemId = null;
} else {
updateData.plexGuid = null;
}
await prisma.audiobook.update({
where: { id: request.audiobook.id },
data: updateData,
});
logger.info(
`Cleared availability status for audiobook ${request.audiobook.id}`
);
} catch (error) {
logger.error(
`Error clearing audiobook status`,
{ error: error instanceof Error ? error.message : String(error) }
);
// Continue with deletion even if this fails
}
// 5. Soft delete request
// 6. Soft delete request
await prisma.request.update({
where: { id: requestId },
data: {
+2 -1
View File
@@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches(
// Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin);
// Get all audiobook records for these ASINs with ALL requests
// Get all audiobook records for these ASINs with ALL audiobook requests (not ebook requests)
const audiobookRecords = await prisma.audiobook.findMany({
where: {
audibleAsin: { in: asins },
@@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches(
requests: {
where: {
deletedAt: null, // Only include active (non-deleted) requests
type: 'audiobook', // Only check audiobook requests, not ebook requests
},
select: {
id: true,
+171 -50
View File
@@ -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,166 @@ export class FileOrganizer {
return result;
}
/**
* 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; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string },
template: string,
loggerConfig?: LoggerConfig,
isIndexerDownload: boolean = false
): 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}`);
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(ebookFile).toLowerCase().slice(1);
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,
metadata.narrator,
metadata.asin,
metadata.year,
metadata.series,
metadata.seriesPart
);
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(ebookFile);
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 (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 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;
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;
}
}
/**
* 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,
};
}
}
/**
+47 -10
View File
@@ -4,13 +4,18 @@
*
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
*/
export type CategoryType = 'audiobook' | 'ebook';
export interface IndexerConfig {
id: number;
name: string;
priority?: number;
categories?: number[];
audiobookCategories?: number[]; // Categories for audiobook searches
ebookCategories?: number[]; // Categories for ebook searches
categories?: number[]; // Legacy field for backwards compatibility
[key: string]: any; // Allow other properties
}
@@ -20,38 +25,70 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
return indexer.categories; // Legacy fallback
}
return [3030]; // Default audiobook category
}
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
*
* @example
* const indexers = [
* { id: 1, categories: [3030] },
* { id: 2, categories: [3030] },
* { id: 3, categories: [3030, 3010] },
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const groups = groupIndexersByCategories(indexers);
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
*/
export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] {
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
const groupMap = new Map<string, IndexerConfig[]>();
for (const indexer of indexers) {
// Get categories, default to [3030] (audiobooks) if not specified
const categories = indexer.categories && indexer.categories.length > 0
? indexer.categories
: [3030];
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
+598 -36
View File
@@ -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,25 @@ 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;
ebookFormat?: string; // Detected ebook format (epub, pdf, mobi, etc.)
}
export class RankingAlgorithm {
/**
* Rank all torrents and return sorted by finalScore (best first)
@@ -300,6 +331,26 @@ export class RankingAlgorithm {
}
/**
* Normalize text for matching by handling CamelCase and punctuation separators
* "VirginaEvans TheCorrespondent" "virgina evans the correspondent"
* "Twelve.Months-Jim.Butcher" "twelve months jim butcher"
* "Author_Name_Book" "author name book"
*/
private normalizeForMatching(text: string): string {
return text
// Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase()
// Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ')
// Replace other punctuation/separators with spaces (preserves apostrophes in contractions)
.replace(/[^\w\s']/g, ' ')
// Collapse multiple spaces
.replace(/\s+/g, ' ')
.trim();
}
/**
* Score title/author match quality (60 points max)
* Title similarity: 0-45 points (heavily weighted!)
@@ -310,10 +361,22 @@ export class RankingAlgorithm {
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): number {
// Normalize whitespace (multiple spaces → single space) for consistent matching
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim();
const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
// Normalize for matching (handles CamelCase, punctuation separators)
const torrentTitle = this.normalizeForMatching(torrent.title);
const requestTitle = this.normalizeForMatching(audiobook.title);
// Parse authors from RAW string first (preserving commas for splitting)
// Then normalize individual authors for matching
const requestAuthorRaw = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
const parsedAuthors = requestAuthorRaw
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize parsed authors for matching (handles CamelCase in author names)
const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a));
// Combined normalized author string for fuzzy matching
const requestAuthorNormalized = normalizedAuthors.join(' ');
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
// Extract significant words (filter out common stop words)
@@ -321,26 +384,37 @@ export class RankingAlgorithm {
const extractWords = (text: string, stopList: string[]): string[] => {
return text
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // Remove punctuation
// Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ')
// Remove other punctuation (but keep apostrophes for contractions)
.replace(/[^\w\s']/g, ' ')
.split(/\s+/)
.filter(word => word.length > 0 && !stopList.includes(word));
};
// Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Work with original title format for bracket detection
const originalTitle = audiobook.title.toLowerCase();
// Extract content in parentheses/brackets as optional
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
const optionalMatches: string[] = [];
let match;
while ((match = optionalPattern.exec(title)) !== null) {
while ((match = optionalPattern.exec(originalTitle)) !== null) {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const required = title.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw);
const optional = optionalMatches.join(' ');
return { required, optional };
@@ -370,7 +444,7 @@ export class RankingAlgorithm {
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
// Only enforced in automatic mode (requireAuthor: true)
// Interactive search (requireAuthor: false) shows all results
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
if (requireAuthor && !this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors)) {
// No high-confidence author match → reject to prevent wrong-author matches
return 0;
}
@@ -378,6 +452,10 @@ export class RankingAlgorithm {
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
// Keep original torrent title (lowercased only) for metadata marker detection
// Markers like [ ] ( ) : are removed by normalization but needed for suffix validation
const torrentTitleOriginal = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
// Try matching with full title first, then fall back to required title (without parentheses)
const titlesToTry = [requestTitle];
if (requiredTitle !== requestTitle) {
@@ -392,20 +470,37 @@ export class RankingAlgorithm {
const beforeTitle = torrentTitle.substring(0, titleIndex);
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
// For metadata marker detection, try to find where the title starts in the ORIGINAL string
// Search for key words from the title to locate position in original
const titleWords = titleToMatch.split(/\s+/).filter(w => w.length > 2);
let afterTitleOriginal = '';
if (titleWords.length > 0) {
// Find the last significant title word in the original string
const lastTitleWord = titleWords[titleWords.length - 1];
const lastWordIdxOriginal = torrentTitleOriginal.lastIndexOf(lastTitleWord);
if (lastWordIdxOriginal !== -1) {
afterTitleOriginal = torrentTitleOriginal.substring(lastWordIdxOriginal + lastTitleWord.length);
}
}
// Extract significant words BEFORE the matched title
const beforeWords = extractWords(beforeTitle, stopWords);
// Title is complete if:
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
// Check ORIGINAL title for metadata markers ([ ] ( ) etc. not normalized away)
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
// Check if afterTitle starts with author name (handles space-separated format like "Title Author Year")
const afterStartsWithAuthor = requestAuthor.length > 2 &&
afterTitle.trim().startsWith(requestAuthor);
// Check if afterTitle starts with any author name (handles space-separated format like "Title Author Year")
const afterStartsWithAuthor = normalizedAuthors.some(author =>
author.length > 2 && afterTitle.trim().startsWith(author)
);
// Check metadata markers in both normalized and original suffixes
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
metadataMarkers.some(marker => afterTitleOriginal.startsWith(marker)) ||
afterStartsWithAuthor;
// Check prefix validity:
@@ -416,16 +511,32 @@ export class RankingAlgorithm {
// Check if title is immediately preceded by a metadata separator
// This handles "Author - Series - 01 - Title" patterns
// Check both normalized and original strings for separators
const precedingText = beforeTitle.trimEnd();
// Also check original string for separators that got normalized away (like colons)
let beforeTitleOriginal = '';
if (titleWords.length > 0) {
const firstTitleWord = titleWords[0];
const firstWordIdxOriginal = torrentTitleOriginal.indexOf(firstTitleWord);
if (firstWordIdxOriginal !== -1) {
beforeTitleOriginal = torrentTitleOriginal.substring(0, firstWordIdxOriginal).trimEnd();
}
}
const titlePrecededBySeparator =
precedingText.endsWith('-') ||
precedingText.endsWith(':') ||
precedingText.endsWith('—');
precedingText.endsWith('—') ||
beforeTitleOriginal.endsWith('-') ||
beforeTitleOriginal.endsWith(':') ||
beforeTitleOriginal.endsWith('—');
// Check if author name appears in the prefix
// Check if any author name appears in the prefix
// This handles "Author Name - Title" patterns
const authorInPrefix = requestAuthor.length > 2 &&
beforeTitle.includes(requestAuthor);
const authorInPrefix = normalizedAuthors.some(author =>
author.length > 2 && beforeTitle.includes(author)
);
const hasAcceptablePrefix =
hasNoWordsPrefix ||
@@ -451,24 +562,18 @@ export class RankingAlgorithm {
}
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
// Parse requested authors (split on separators, filter out roles)
const requestAuthors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Check how many authors appear in torrent title (exact substring match)
const authorMatches = requestAuthors.filter(author =>
const authorMatches = normalizedAuthors.filter(author =>
torrentTitle.includes(author)
);
let authorScore = 0;
if (authorMatches.length > 0) {
// Exact substring match → proportional credit
authorScore = (authorMatches.length / requestAuthors.length) * 15;
authorScore = (authorMatches.length / normalizedAuthors.length) * 15;
} else {
// No exact match → use fuzzy similarity for partial credit
authorScore = compareTwoStrings(requestAuthor, torrentTitle) * 15;
authorScore = compareTwoStrings(requestAuthorNormalized, torrentTitle) * 15;
}
return Math.min(60, titleScore + authorScore);
@@ -476,22 +581,16 @@ export class RankingAlgorithm {
/**
* Check if author is present in torrent title with high confidence
* Handles variations: middle initials, spacing, punctuation, name order
* Uses pre-parsed and normalized authors array
*
* @param torrentTitle - Normalized torrent title (lowercase)
* @param requestAuthor - Normalized author name (lowercase)
* @param torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
* @param normalizedAuthors - Array of normalized author names (roles already filtered)
* @returns true if at least ONE author is present with high confidence
*/
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
// Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
private checkAuthorPresenceWithParsed(torrentTitle: string, normalizedAuthors: string[]): boolean {
// At least ONE author must match with high confidence
return authors.some(author => {
// Check 1: Exact substring match
return normalizedAuthors.some(author => {
// Check 1: Exact substring match (works well now that both are normalized)
if (torrentTitle.includes(author)) {
return true;
}
@@ -507,6 +606,7 @@ export class RankingAlgorithm {
// Check 3: Core name components (first + last name present within 30 chars)
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
// Now also handles: "VirginaEvans" → "virgina evans" (after normalization)
const words = author.split(/\s+/).filter(w => w.length > 1);
if (words.length >= 2) {
const firstName = words[0];
@@ -528,6 +628,27 @@ export class RankingAlgorithm {
});
}
/**
* Check if author is present in torrent title with high confidence
* Handles variations: middle initials, spacing, punctuation, name order, CamelCase
*
* @param torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
* @param requestAuthor - Raw author string (will be parsed and normalized internally)
* @returns true if at least ONE author is present with high confidence
*/
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
// Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize each author for matching
const normalizedAuthors = authors.map(a => this.normalizeForMatching(a));
return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors);
}
/**
* Detect format from torrent title
*/
@@ -622,6 +743,424 @@ 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) => {
// Detect ebook format from title
const detectedFormat = this.detectEbookFormat(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),
},
ebookFormat: detectedFormat !== 'unknown' ? detectedFormat : undefined,
};
});
// 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
* Handles formats in various positions: .epub, (epub), [epub], " epub"
*/
private detectEbookFormat(torrent: TorrentResult): string {
const title = torrent.title.toLowerCase();
// Check for common ebook format extensions/keywords
// Patterns: .format, (format), [format], " format", "_format"
const formats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
for (const format of formats) {
if (
title.includes(`.${format}`) || // file.epub
title.includes(`(${format})`) || // (epub)
title.includes(`[${format}]`) || // [epub]
title.includes(` ${format}`) || // " epub" (space before)
title.includes(`_${format}`) || // _epub (underscore)
title.endsWith(format) // ends with format
) {
return format;
}
}
// 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;
}
}
// =========================================================================
// 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
@@ -689,3 +1228,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),
}));
}
+5 -1
View File
@@ -36,7 +36,11 @@ export const TORRENT_CATEGORIES: TorrentCategory[] = [
},
];
export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook
export const DEFAULT_AUDIOBOOK_CATEGORIES = [3030]; // Audio/Audiobook
export const DEFAULT_EBOOK_CATEGORIES = [7020]; // Books/EBook
// Legacy alias for backwards compatibility
export const DEFAULT_CATEGORIES = DEFAULT_AUDIOBOOK_CATEGORIES;
/**
* Get all child IDs for a parent category