Add multi-source ebook support and per-indexer categories

Introduces granular toggles for Anna's Archive and Indexer Search as ebook sources, updates settings UI to a three-section layout, and documents the new configuration. Adds per-indexer category configuration with separate tabs for audiobooks and ebooks, updates API routes and types for new settings, and ensures legacy config migration. Indexer grouping and file organization logic now support the new category structure and ebook source toggles.
This commit is contained in:
kikootwo
2026-01-30 22:12:24 -05:00
parent 590f089733
commit 5a0cce7985
19 changed files with 563 additions and 212 deletions
+1 -1
View File
@@ -497,7 +497,7 @@ function AdminDashboardContent() {
</h2>
<RecentRequestsTable
requests={requestsData.requests}
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
</div>
+11 -4
View File
@@ -103,12 +103,17 @@ 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;
}
/**
@@ -143,7 +148,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 +164,8 @@ export interface SavedIndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
ebookCategories: number[]; // Category IDs for ebook searches (default: [7020])
}
/**
+2 -1
View File
@@ -106,7 +106,8 @@ export default function AdminSettings() {
protocol: idx.protocol,
priority: idx.priority,
rssEnabled: idx.rssEnabled,
categories: idx.categories || [3030],
audiobookCategories: idx.audiobookCategories || [3030],
ebookCategories: idx.ebookCategories || [7020],
};
// Add protocol-specific fields
+205 -134
View File
@@ -1,6 +1,11 @@
/**
* Component: E-book Settings Tab
* Documentation: documentation/settings-pages.md
*
* Three-section layout:
* 1. Anna's Archive - Direct HTTP downloads from Anna's Archive
* 2. Indexer Search - Search via Prowlarr indexers (future feature)
* 3. General Settings - Shared settings like preferred format
*/
'use client';
@@ -27,167 +32,233 @@ 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>
)}
{/* Coming soon notice */}
{ebook.indexerSearchEnabled && (
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-3">
<p className="text-sm text-purple-800 dark:text-purple-200">
<strong>Coming Soon:</strong> Indexer search for e-books is not yet implemented.
Enabling this setting prepares your configuration for when the feature is released.
</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>
</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,7 +77,8 @@ 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 || '',
@@ -98,6 +99,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 +111,6 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
updateEbook,
testFlaresolverrConnection,
saveSettings,
isAnySourceEnabled,
};
}
+17 -8
View File
@@ -13,8 +13,8 @@ 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 } = await request.json();
// Validate format
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
@@ -25,8 +25,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 +46,32 @@ 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',
},
// 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
+8 -2
View File
@@ -100,10 +100,16 @@ 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',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
+22 -6
View File
@@ -22,14 +22,30 @@ export async function POST(
try {
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 }
);
}
// If only indexer search is enabled (not yet implemented), return error
if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'E-book indexer search is not yet implemented. Enable Anna\'s Archive to fetch e-books.' },
{ status: 400 }
);
}
+2 -1
View File
@@ -26,7 +26,8 @@ interface SelectedIndexer {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Categories for audiobook searches
ebookCategories: number[]; // Categories for ebook searches
}
export function ProwlarrStep({
@@ -16,12 +16,15 @@ import {
interface CategoryTreeViewProps {
selectedCategories: number[];
onChange: (categories: number[]) => void;
defaultCategories?: number[]; // Categories to show "Default" badge for (e.g., [3030] for audiobook, [7020] for ebook)
}
export function CategoryTreeView({
selectedCategories,
onChange,
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
}: CategoryTreeViewProps) {
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
const handleParentToggle = (parentId: number) => {
const childIds = getChildIds(parentId);
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
@@ -75,7 +78,7 @@ export function CategoryTreeView({
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{category.id}]
</span>
{category.id === 3030 && (
{isDefaultCategory(category.id) && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
@@ -109,7 +112,7 @@ export function CategoryTreeView({
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{child.id}]
</span>
{child.id === 3030 && (
{isDefaultCategory(child.id) && (
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Default
</span>
@@ -1,6 +1,9 @@
/**
* Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/
'use client';
@@ -10,7 +13,9 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView';
import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook';
interface IndexerConfigModalProps {
isOpen: boolean;
@@ -27,7 +32,8 @@ interface IndexerConfigModalProps {
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[];
ebookCategories: number[];
};
onSave: (config: {
id: number;
@@ -37,7 +43,8 @@ interface IndexerConfigModalProps {
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[];
ebookCategories: number[];
}) => void;
}
@@ -56,7 +63,8 @@ export function IndexerConfigModal({
seedingTimeMinutes: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
ebookCategories: DEFAULT_EBOOK_CATEGORIES,
};
// Form state
@@ -72,15 +80,24 @@ export function IndexerConfigModal({
const [rssEnabled, setRssEnabled] = useState(
initialConfig?.rssEnabled ?? defaults.rssEnabled
);
const [selectedCategories, setSelectedCategories] = useState<number[]>(
initialConfig?.categories ?? defaults.categories
// Dual category state
const [audiobookCategories, setAudiobookCategories] = useState<number[]>(
initialConfig?.audiobookCategories ?? defaults.audiobookCategories
);
const [ebookCategories, setEbookCategories] = useState<number[]>(
initialConfig?.ebookCategories ?? defaults.ebookCategories
);
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
categories?: string;
audiobookCategories?: string;
ebookCategories?: string;
}>({});
// Reset form when modal opens or indexer changes
@@ -91,14 +108,17 @@ export function IndexerConfigModal({
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setSelectedCategories(defaults.categories);
setAudiobookCategories(defaults.audiobookCategories);
setEbookCategories(defaults.ebookCategories);
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
setEbookCategories(initialConfig?.ebookCategories ?? defaults.ebookCategories);
}
setActiveTab('audiobook');
setErrors({});
}
}, [isOpen, mode, indexer.id]);
@@ -114,8 +134,12 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (selectedCategories.length === 0) {
newErrors.categories = 'At least one category must be selected';
if (audiobookCategories.length === 0) {
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
}
if (ebookCategories.length === 0) {
newErrors.ebookCategories = 'At least one ebook category must be selected';
}
setErrors(newErrors);
@@ -124,6 +148,12 @@ export function IndexerConfigModal({
const handleSave = () => {
if (!validate()) {
// If there's a category error, switch to the relevant tab
if (errors.audiobookCategories && activeTab !== 'audiobook') {
setActiveTab('audiobook');
} else if (errors.ebookCategories && activeTab !== 'ebook') {
setActiveTab('ebook');
}
return;
}
@@ -133,7 +163,8 @@ export function IndexerConfigModal({
protocol: indexer.protocol,
priority,
rssEnabled: indexer.supportsRss ? rssEnabled : false,
categories: selectedCategories,
audiobookCategories,
ebookCategories,
};
// Add protocol-specific fields
@@ -168,6 +199,12 @@ export function IndexerConfigModal({
}
};
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
return (
<Modal
isOpen={isOpen}
@@ -287,23 +324,62 @@ export function IndexerConfigModal({
)}
</div>
{/* Categories */}
{/* Categories with Tabs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Categories
</label>
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
type="button"
onClick={() => setActiveTab('audiobook')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'audiobook'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
}`}
>
AudioBook
{errors.audiobookCategories && (
<span className="ml-2 text-red-500">!</span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('ebook')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'ebook'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
}`}
>
EBook
{errors.ebookCategories && (
<span className="ml-2 text-red-500">!</span>
)}
</button>
</div>
{/* Tab Content */}
<div className="max-h-72 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<CategoryTreeView
selectedCategories={selectedCategories}
onChange={setSelectedCategories}
selectedCategories={currentCategories}
onChange={setCurrentCategories}
defaultCategories={defaultForTab}
/>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Select categories to search on this indexer. Parent selection locks all children as selected.
{activeTab === 'audiobook'
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
</p>
{errors.categories && (
{currentError && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.categories}
{currentError}
</p>
)}
</div>
@@ -28,7 +28,8 @@ interface SavedIndexerConfig {
seedingTimeMinutes?: number; // Torrents only
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
categories: number[];
audiobookCategories: number[]; // Categories for audiobook searches
ebookCategories: number[]; // Categories for ebook searches
}
interface IndexerManagementProps {
+25 -5
View File
@@ -608,6 +608,10 @@ async function processEbookOrganization(
/**
* 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,
@@ -617,15 +621,31 @@ async function createEbookRequestIfEnabled(
logger: RMABLogger
): Promise<void> {
try {
// Check if ebook downloads are enabled
// Check which ebook sources are enabled
const configService = getConfigService();
const ebookEnabled = await configService.get('ebook_sidecar_enabled');
const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled');
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
if (ebookEnabled !== 'true') {
logger.info('Ebook downloads disabled, skipping ebook request creation');
// 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;
}
// If only indexer search is enabled (not yet implemented), log and skip
if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) {
logger.info('Ebook indexer search is enabled but not yet implemented, skipping ebook request creation');
return;
}
// Anna's Archive is enabled - proceed with ebook request creation
// Check if an ebook request already exists for this parent
const existingEbookRequest = await prisma.request.findFirst({
where: {
@@ -656,7 +676,7 @@ async function createEbookRequestIfEnabled(
logger.info(`Created ebook request ${ebookRequest.id}`);
// Trigger ebook search job
// Trigger ebook search job (Anna's Archive)
const jobQueue = getJobQueueService();
await jobQueue.addSearchEbookJob(ebookRequest.id, {
id: audiobook.id,
+47 -10
View File
@@ -4,13 +4,18 @@
*
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
*/
export type CategoryType = 'audiobook' | 'ebook';
export interface IndexerConfig {
id: number;
name: string;
priority?: number;
categories?: number[];
audiobookCategories?: number[]; // Categories for audiobook searches
ebookCategories?: number[]; // Categories for ebook searches
categories?: number[]; // Legacy field for backwards compatibility
[key: string]: any; // Allow other properties
}
@@ -20,38 +25,70 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
return indexer.categories; // Legacy fallback
}
return [3030]; // Default audiobook category
}
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
*
* @example
* const indexers = [
* { id: 1, categories: [3030] },
* { id: 2, categories: [3030] },
* { id: 3, categories: [3030, 3010] },
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const groups = groupIndexersByCategories(indexers);
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
*/
export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] {
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
const groupMap = new Map<string, IndexerConfig[]>();
for (const indexer of indexers) {
// Get categories, default to [3030] (audiobooks) if not specified
const categories = indexer.categories && indexer.categories.length > 0
? indexer.categories
: [3030];
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
+5 -1
View File
@@ -36,7 +36,11 @@ export const TORRENT_CATEGORIES: TorrentCategory[] = [
},
];
export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook
export const DEFAULT_AUDIOBOOK_CATEGORIES = [3030]; // Audio/Audiobook
export const DEFAULT_EBOOK_CATEGORIES = [7020]; // Books/EBook
// Legacy alias for backwards compatibility
export const DEFAULT_CATEGORIES = DEFAULT_AUDIOBOOK_CATEGORIES;
/**
* Get all child IDs for a parent category