mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Merge branch 'ebook-piecewise'
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user