From 5a0cce79856fa2d8b0653f7ab2b91cd9b050d25c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 30 Jan 2026 22:12:24 -0500 Subject: [PATCH] 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. --- documentation/TABLEOFCONTENTS.md | 7 +- documentation/integrations/ebook-sidecar.md | 43 ++- documentation/settings-pages.md | 58 ++- src/app/admin/page.tsx | 2 +- src/app/admin/settings/lib/types.ts | 15 +- src/app/admin/settings/page.tsx | 3 +- .../admin/settings/tabs/EbookTab/EbookTab.tsx | 339 +++++++++++------- .../tabs/EbookTab/useEbookSettings.ts | 9 +- src/app/api/admin/settings/ebook/route.ts | 25 +- .../admin/settings/prowlarr/indexers/route.ts | 16 +- src/app/api/admin/settings/route.ts | 10 +- .../api/requests/[id]/fetch-ebook/route.ts | 28 +- src/app/setup/steps/ProwlarrStep.tsx | 3 +- .../admin/indexers/CategoryTreeView.tsx | 7 +- .../admin/indexers/IndexerConfigModal.tsx | 114 +++++- .../admin/indexers/IndexerManagement.tsx | 3 +- .../processors/organize-files.processor.ts | 30 +- src/lib/utils/indexer-grouping.ts | 57 ++- src/lib/utils/torrent-categories.ts | 6 +- 19 files changed, 563 insertions(+), 212 deletions(-) diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5373898..5c99244 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -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) diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 7e99497..7098442 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.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 diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index bce650e..9da5277 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -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 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 96bdbd8..f01e8dc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -497,7 +497,7 @@ function AdminDashboardContent() { diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 3fd8a46..23cb2bf 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -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]) } /** diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 3f1636c..dec2cd8 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -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 diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx index 3293014..7493ca2 100644 --- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -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 (
+ {/* Header */}

E-book Sidecar

- 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.

- {/* Enable Toggle */} -
-
- updateEbook('enabled', e.target.checked)} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- When enabled, the system will search for e-books matching your audiobook's ASIN - and download them to the same folder. -

+ {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 1: ANNA'S ARCHIVE + ═══════════════════════════════════════════════════════════════════════ */} +
+
+

+ Anna's Archive +

+
+
+ {/* Enable Toggle */} +
+ updateEbook('annasArchiveEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Download e-books directly from Anna's Archive using ASIN or title matching. +

+
+ + {/* Anna's Archive specific settings - only shown when enabled */} + {ebook.annasArchiveEnabled && ( + <> + {/* Base URL */} +
+ + updateEbook('baseUrl', e.target.value)} + placeholder="https://annas-archive.li" + className="font-mono" + /> +

+ Change this if the primary Anna's Archive mirror is unavailable. +

+
+ + {/* FlareSolverr URL */} +
+
+ +
+ updateEbook('flaresolverrUrl', e.target.value)} + placeholder="http://localhost:8191" + className="font-mono flex-1" + /> + +
+

+ FlareSolverr helps bypass Cloudflare protection. +

+ {flaresolverrTestResult && ( +
+ {flaresolverrTestResult.success ? '✓ ' : '✗ '} + {flaresolverrTestResult.message} +
+ )} +
+ {!ebook.flaresolverrUrl && ( +
+

+ Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive + has Cloudflare protection enabled. +

+
+ )} +
+ + )}
- {/* Format Selection */} - {ebook.enabled && ( -
- - -

- EPUB is recommended for most e-readers. "Any format" will download the first available format. -

+ {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 2: INDEXER SEARCH + ═══════════════════════════════════════════════════════════════════════ */} +
+
+

+ Indexer Search +

- )} - - {/* Base URL (Advanced) */} - {ebook.enabled && ( -
- - updateEbook('baseUrl', e.target.value)} - placeholder="https://annas-archive.li" - className="font-mono" - /> -

- Change this if the primary Anna's Archive mirror is unavailable. -

-
- )} - - {/* FlareSolverr (Optional - for Cloudflare bypass) */} - {ebook.enabled && ( -
-
- -
- updateEbook('flaresolverrUrl', e.target.value)} - placeholder="http://localhost:8191" - className="font-mono flex-1" - /> - + Enable Indexer Search + +

+ Search for e-books via Prowlarr indexers (torrent/NZB sources). +

-

- FlareSolverr helps bypass Cloudflare protection on Anna's Archive. - Leave empty if not needed. -

- {flaresolverrTestResult && ( -
- {flaresolverrTestResult.success ? '✓ ' : '✗ '} - {flaresolverrTestResult.message} -
- )}
- {!ebook.flaresolverrUrl && ( -
-

- Note: 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 && ( +

+

+ Configure Categories: E-book category settings are configured per-indexer + in the Indexers tab. Look for the "EBook" tab when + editing an indexer. +

+
+ )} + + {/* Coming soon notice */} + {ebook.indexerSearchEnabled && ( +
+

+ Coming Soon: Indexer search for e-books is not yet implemented. + Enabling this setting prepares your configuration for when the feature is released.

)}
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 3: GENERAL SETTINGS + ═══════════════════════════════════════════════════════════════════════ */} + {isAnySourceEnabled && ( +
+
+

+ General Settings +

+
+
+ {/* Preferred Format */} +
+ + +

+ EPUB is recommended for most e-readers. "Any format" accepts the first available. +

+
+
+
)} - {/* Info Box */} -
-

- How it works -

-
    -
  • • Searches Anna's Archive in two ways:
  • -
  • 1. First tries ASIN (exact match - most accurate)
  • -
  • 2. Falls back to title + author (with book/language filters)
  • -
  • • Downloads matching e-book in your preferred format
  • -
  • • Places e-book file in the same folder as the audiobook
  • -
  • • If no match is found or download fails, audiobook download continues normally
  • -
  • • Completely optional and non-blocking
  • -
-
- - {/* Warning Box */} -
-

- ⚠️ Important Note -

-

- 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. -

-
+ {/* How it works - only show when Anna's Archive is enabled */} + {ebook.annasArchiveEnabled && ( +
+

+ How Anna's Archive works +

+
    +
  • • Searches by ASIN first (exact match), then title + author
  • +
  • • Downloads matching e-book in your preferred format
  • +
  • • Places e-book file in the same folder as the audiobook
  • +
  • • If no match is found, audiobook download continues normally
  • +
+
+ )} {/* Save Button */}
diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts index e57fbd7..41485a1 100644 --- a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -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, }; } diff --git a/src/app/api/admin/settings/ebook/route.ts b/src/app/api/admin/settings/ebook/route.ts index 0a9115f..60a16ef 100644 --- a/src/app/api/admin/settings/ebook/route.ts +++ b/src/app/api/admin/settings/ebook/route.ts @@ -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', diff --git a/src/app/api/admin/settings/prowlarr/indexers/route.ts b/src/app/api/admin/settings/prowlarr/indexers/route.ts index d422936..1a54d47 100644 --- a/src/app/api/admin/settings/prowlarr/indexers/route.ts +++ b/src/app/api/admin/settings/prowlarr/indexers/route.ts @@ -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 diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 059b4c7..f90127c 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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', diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts index 1714de9..e6f99b7 100644 --- a/src/app/api/requests/[id]/fetch-ebook/route.ts +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -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 } ); } diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx index 537a140..4f22d5c 100644 --- a/src/app/setup/steps/ProwlarrStep.tsx +++ b/src/app/setup/steps/ProwlarrStep.tsx @@ -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({ diff --git a/src/components/admin/indexers/CategoryTreeView.tsx b/src/components/admin/indexers/CategoryTreeView.tsx index 98ec21c..c2ff92d 100644 --- a/src/components/admin/indexers/CategoryTreeView.tsx +++ b/src/components/admin/indexers/CategoryTreeView.tsx @@ -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({ [{category.id}] - {category.id === 3030 && ( + {isDefaultCategory(category.id) && ( Default @@ -109,7 +112,7 @@ export function CategoryTreeView({ [{child.id}] - {child.id === 3030 && ( + {isDefaultCategory(child.id) && ( Default diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx index 77af36f..8d84eb0 100644 --- a/src/components/admin/indexers/IndexerConfigModal.tsx +++ b/src/components/admin/indexers/IndexerConfigModal.tsx @@ -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( - initialConfig?.categories ?? defaults.categories + + // Dual category state + const [audiobookCategories, setAudiobookCategories] = useState( + initialConfig?.audiobookCategories ?? defaults.audiobookCategories ); + const [ebookCategories, setEbookCategories] = useState( + initialConfig?.ebookCategories ?? defaults.ebookCategories + ); + + // Tab state for categories + const [activeTab, setActiveTab] = useState('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 ( - {/* Categories */} + {/* Categories with Tabs */}
-
+ + {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} +
+

- 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]'}

- {errors.categories && ( + + {currentError && (

- {errors.categories} + {currentError}

)}
diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx index cb96618..da95b3f 100644 --- a/src/components/admin/indexers/IndexerManagement.tsx +++ b/src/components/admin/indexers/IndexerManagement.tsx @@ -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 { diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 9976f20..55a314c 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -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 { 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, diff --git a/src/lib/utils/indexer-grouping.ts b/src/lib/utils/indexer-grouping.ts index f6c72f3..c3c413c 100644 --- a/src/lib/utils/indexer-grouping.ts +++ b/src/lib/utils/indexer-grouping.ts @@ -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(); 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 diff --git a/src/lib/utils/torrent-categories.ts b/src/lib/utils/torrent-categories.ts index 55006d9..1b27559 100644 --- a/src/lib/utils/torrent-categories.ts +++ b/src/lib/utils/torrent-categories.ts @@ -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