mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -40,10 +40,13 @@
|
||||
|
||||
## E-book Support (First-Class)
|
||||
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Multi-source ebook downloads (Anna's Archive + Indexer Search)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **ASIN-based matching, format selection** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook ranking algorithm (inverted size scoring)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Direct HTTP downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook delete behavior (files only)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||
|
||||
## Automation Pipeline
|
||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||
@@ -111,7 +114,9 @@
|
||||
**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
||||
**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md)
|
||||
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
**"How do I configure ebook sources (Anna's Archive vs Indexer)?"** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
**"How do I configure ebook categories per indexer?"** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||
**"What happens when I delete an ebook request?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
||||
**"Why do ebook requests have an orange badge?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#ui-representation)
|
||||
**"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# E-book Support
|
||||
|
||||
**Status:** ✅ Implemented | First-class ebook requests with Anna's Archive integration
|
||||
**Status:** ✅ Implemented | First-class ebook requests with multi-source support (Anna's Archive + future Indexer Search)
|
||||
|
||||
## Overview
|
||||
Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if enabled). Ebooks are downloaded directly from Anna's Archive via HTTP.
|
||||
Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if a source is enabled). Supports multiple sources: Anna's Archive (direct HTTP) and Indexer Search (via Prowlarr, coming soon).
|
||||
|
||||
## Key Details
|
||||
|
||||
@@ -14,9 +14,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
- **UI Badge:** Orange (#f16f19) ebook badge to distinguish from audiobooks
|
||||
- **Separate Tracking:** Own progress, status, and error handling
|
||||
|
||||
### Flow
|
||||
### Flow (Anna's Archive)
|
||||
1. Audiobook organization completes
|
||||
2. Ebook request created automatically (if enabled)
|
||||
2. Ebook request created automatically (if Anna's Archive enabled)
|
||||
3. `search_ebook` job searches Anna's Archive
|
||||
4. `start_direct_download` downloads via HTTP
|
||||
5. `organize_files` copies to audiobook folder
|
||||
@@ -25,14 +25,31 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
|
||||
### Configuration
|
||||
|
||||
**Admin Settings → E-book Sidecar tab**
|
||||
**Admin Settings → E-book Sidecar tab** (3 sections)
|
||||
|
||||
#### Section 1: Anna's Archive
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||
|
||||
#### Section 2: Indexer Search
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (not yet implemented) |
|
||||
|
||||
*Note: Ebook categories are configured per-indexer in Settings → Indexers → Edit Indexer → EBook tab*
|
||||
|
||||
#### Section 3: General Settings
|
||||
| Key | Default | Options | Description |
|
||||
|-----|---------|---------|-------------|
|
||||
| `ebook_sidecar_enabled` | `false` | `true/false` | Enable feature |
|
||||
| `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | URL | Base URL |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | URL | FlareSolverr proxy (optional) |
|
||||
|
||||
### Source Priority
|
||||
- If **Anna's Archive** is enabled → Use Anna's Archive (current behavior)
|
||||
- If **only Indexer Search** is enabled → Log "not yet implemented", skip gracefully
|
||||
- If **both disabled** → Ebook downloads disabled entirely
|
||||
|
||||
## Database Schema
|
||||
|
||||
@@ -173,11 +190,19 @@ Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
||||
|
||||
## Limitations
|
||||
|
||||
1. Single source (Anna's Archive) - future Prowlarr support stubbed
|
||||
1. Indexer Search not yet implemented (settings ready, search stubbed)
|
||||
2. Title search may return wrong book for common titles
|
||||
3. Download speed depends on file server load
|
||||
4. English books only (title search filter)
|
||||
|
||||
## Indexer Categories
|
||||
|
||||
Indexer configuration supports separate category arrays for audiobooks and ebooks:
|
||||
- **Audiobook Categories:** Default `[3030]` (Audio/Audiobook)
|
||||
- **Ebook Categories:** Default `[7020]` (Books/EBook)
|
||||
|
||||
Categories are configured per-indexer via the tabbed interface in the Edit Indexer modal.
|
||||
|
||||
## Related
|
||||
- [File Organization](../phase3/file-organization.md) - Ebook organization
|
||||
- [Settings Pages](../settings-pages.md) - Configuration UI
|
||||
|
||||
@@ -66,11 +66,63 @@ src/app/admin/settings/
|
||||
|
||||
1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle
|
||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
|
||||
4. **Download Client** - Type, URL, credentials (masked)
|
||||
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
|
||||
6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||
7. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
|
||||
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
|
||||
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||
8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
|
||||
|
||||
## E-book Sidecar
|
||||
|
||||
**Purpose:** Configure ebook download sources and preferences to accompany audiobook downloads.
|
||||
|
||||
**Tab Structure (3 sections):**
|
||||
|
||||
1. **Anna's Archive Section**
|
||||
- Enable toggle for Anna's Archive downloads
|
||||
- Base URL (default: `https://annas-archive.li`)
|
||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||
|
||||
2. **Indexer Search Section**
|
||||
- Enable toggle for indexer-based ebook search (not yet implemented)
|
||||
- Hint directing users to Indexers tab for category configuration
|
||||
|
||||
3. **General Settings Section** (visible when any source enabled)
|
||||
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
|
||||
|
||||
**Configuration Keys:**
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive |
|
||||
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (stubbed) |
|
||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||
|
||||
**Behavior:**
|
||||
- If Anna's Archive enabled → Downloads work (current implementation)
|
||||
- If only Indexer Search enabled → Gracefully logs "not yet implemented"
|
||||
- If both disabled → Ebook downloads completely off
|
||||
|
||||
## Indexer Categories (Tabbed)
|
||||
|
||||
**Purpose:** Configure separate category sets for audiobook and ebook searches per indexer.
|
||||
|
||||
**UI:** Edit Indexer modal has Categories section with two tabs:
|
||||
- **AudioBook tab** - Categories for audiobook searches (default: `[3030]`)
|
||||
- **EBook tab** - Categories for ebook searches (default: `[7020]`)
|
||||
|
||||
**Storage:** `prowlarr_indexers` JSON config stores:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "MyIndexer",
|
||||
"audiobookCategories": [3030],
|
||||
"ebookCategories": [7020],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Audible Region
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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