From 1afab5d47fd67285cb82cc9fb2417b551613ee5c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 2 Feb 2026 19:59:58 -0500 Subject: [PATCH] Add interactive ebook search & selection Introduce interactive ebook support: adds two API endpoints to search (interactive-search-ebook) and create/select ebook requests (select-ebook), plus server-side handlers to route Anna's Archive (direct) and indexer (torrent/NZB) downloads. Frontend: extend RequestActionsDropdown and InteractiveTorrentSearchModal to support an "ebook" search mode and selection flow, and add hooks (useInteractiveSearchEbook / useSelectEbook). Settings: add ebook_auto_grab_enabled with UI toggle and enforce disabling when no ebook sources are enabled; settings GET/PUT updated to persist the flag (default = true to preserve behavior). Documentation updated (scheduler, ebook-sidecar, settings pages) and ranking algorithm docs/tests extended to cover ebook-related normalization and matching cases. Includes logging and ranking integration for indexer results and normalization for Anna's Archive handling. --- documentation/backend/services/scheduler.md | 4 +- documentation/integrations/ebook-sidecar.md | 3 + documentation/phase3/ranking-algorithm.md | 15 +- documentation/settings-pages.md | 3 + .../components/RequestActionsDropdown.tsx | 50 +- src/app/admin/settings/lib/types.ts | 1 + .../admin/settings/tabs/EbookTab/EbookTab.tsx | 23 + .../tabs/EbookTab/useEbookSettings.ts | 1 + src/app/api/admin/settings/ebook/route.ts | 11 +- src/app/api/admin/settings/route.ts | 2 + .../[id]/interactive-search-ebook/route.ts | 430 ++++++++++++++++++ .../api/requests/[id]/select-ebook/route.ts | 258 +++++++++++ .../InteractiveTorrentSearchModal.tsx | 141 ++++-- src/lib/hooks/useRequests.ts | 85 ++++ .../processors/monitor-rss-feeds.processor.ts | 38 +- .../processors/organize-files.processor.ts | 10 +- .../retry-missing-torrents.processor.ts | 35 +- src/lib/utils/ranking-algorithm.ts | 191 ++++++-- tests/utils/ranking-algorithm.test.ts | 153 +++++++ 19 files changed, 1339 insertions(+), 115 deletions(-) create mode 100644 src/app/api/requests/[id]/interactive-search-ebook/route.ts create mode 100644 src/app/api/requests/[id]/select-ebook/route.ts diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index 23c456d..202c2c3 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible 1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup) 2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default 3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default -4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), enabled by default +4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default 5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default 6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default -7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (limit 100), triggers search jobs for matches, enabled by default +7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default ## Architecture: Bull + Cron diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 3c97b51..3116e30 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -65,6 +65,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, | Key | Default | Options | Description | |-----|---------|---------|-------------| | `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format | +| `ebook_auto_grab_enabled` | `true` | `true, false` | Auto-create ebook requests after audiobook downloads | + +*Note: Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.* ## Database Schema diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 97d62b6..8204a2f 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -1,7 +1,7 @@ # Intelligent Ranking Algorithm **Status:** ✅ Implemented | Comprehensive edge case test coverage -**Tests:** tests/utils/ranking-algorithm.test.ts (73 test cases) +**Tests:** tests/utils/ranking-algorithm.test.ts (80+ test cases) Evaluates and scores torrents to automatically select best audiobook download. @@ -19,6 +19,7 @@ Evaluates and scores torrents to automatically select best audiobook download. - ✅ **Author presence check (10 tests)** - ✅ **Context-aware filtering (3 tests)** - ✅ **API compatibility (2 tests)** +- ✅ **CamelCase and punctuation separator handling (7 tests)** **Tested edge cases prevent regressions from previous tweaks:** - "We Are Legion (We Are Bob)" matching with/without subtitle @@ -35,6 +36,18 @@ Evaluates and scores torrents to automatically select best audiobook download. **1. Title/Author Match (60 pts max) - MOST IMPORTANT** +**Pre-Processing: Text Normalization** +- All titles and author names are normalized before matching +- **CamelCase splitting:** `"TheCorrespondent"` → `"the correspondent"` +- **Punctuation to spaces:** `"Twelve.Months-Jim"` → `"twelve months jim"` +- **Preserves apostrophes:** `"O'Brien"` remains `"o'brien"` +- Handles common indexer naming patterns (NZB, torrent scene releases) + +**Examples of normalization:** +- `"VirginaEvans TheCorrespondent"` → `"virgina evans the correspondent"` +- `"Twelve.Months-Jim.Butcher"` → `"twelve months jim butcher"` +- `"Author_Name-Book.Title.2024"` → `"author name book title 2024"` + **Multi-Stage Matching:** **Stage 1: Word Coverage Filter (MANDATORY)** diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 6e215e0..935dfe9 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -90,6 +90,7 @@ src/app/admin/settings/ 3. **General Settings Section** (visible when any source enabled) - Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any + - Auto-grab toggle: Automatically create ebook requests after audiobook downloads **Configuration Keys:** | Key | Default | Description | @@ -97,6 +98,7 @@ src/app/admin/settings/ | `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive | | `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr | | `ebook_sidecar_preferred_format` | `epub` | Preferred format | +| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads | | `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror | | `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL | @@ -104,6 +106,7 @@ src/app/admin/settings/ - If Anna's Archive enabled → Searches Anna's Archive first - If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled - If both disabled → Ebook downloads completely off +- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work) ## Indexer Categories (Tabbed) diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 884d867..a653ca6 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -40,6 +40,7 @@ export function RequestActionsDropdown({ }: RequestActionsDropdownProps) { const [isOpen, setIsOpen] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); + const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); // Determine request type @@ -80,7 +81,7 @@ export function RequestActionsDropdown({ const canViewSource = !!viewSourceUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); - // "Try to fetch Ebook" only for audiobook requests + // Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside @@ -114,6 +115,11 @@ export function RequestActionsDropdown({ setShowInteractiveSearch(true); }; + const handleInteractiveSearchEbook = () => { + setIsOpen(false); + setShowInteractiveSearchEbook(true); + }; + const handleCancel = async () => { setIsOpen(false); if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { @@ -224,7 +230,7 @@ export function RequestActionsDropdown({ )} - {/* Fetch E-book */} + {/* Grab E-book (automatic) */} {canFetchEbook && ( + )} + + {/* Interactive Search E-book */} + {canFetchEbook && ( + )} @@ -332,7 +362,7 @@ export function RequestActionsDropdown({ {/* Dropdown menu (rendered via portal) */} {typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)} - {/* Interactive Search Modal */} + {/* Interactive Search Modal (Audiobook) */} setShowInteractiveSearch(false)} @@ -342,6 +372,18 @@ export function RequestActionsDropdown({ author: request.author, }} /> + + {/* Interactive Search Modal (Ebook) */} + setShowInteractiveSearchEbook(false)} + requestId={request.requestId} + audiobook={{ + title: request.title, + author: request.author, + }} + searchMode="ebook" + /> ); } diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 23cb2bf..c4d1f3a 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -114,6 +114,7 @@ export interface EbookSettings { flaresolverrUrl: string; // General settings (shared across sources) preferredFormat: string; + autoGrabEnabled: boolean; } /** diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx index 3c8aefe..90c35be 100644 --- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -231,6 +231,29 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E EPUB is recommended for most e-readers. "Any format" accepts the first available.

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

+ When enabled, ebook requests are created automatically after audiobook downloads complete. + When disabled, use the "Fetch Ebook" button on completed requests. +

+
+
)} diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts index 41485a1..381fe94 100644 --- a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -82,6 +82,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa format: ebook.preferredFormat || 'epub', baseUrl: ebook.baseUrl || 'https://annas-archive.li', flaresolverrUrl: ebook.flaresolverrUrl || '', + autoGrabEnabled: ebook.autoGrabEnabled ?? true, }), }); diff --git a/src/app/api/admin/settings/ebook/route.ts b/src/app/api/admin/settings/ebook/route.ts index 60a16ef..0f9a0c8 100644 --- a/src/app/api/admin/settings/ebook/route.ts +++ b/src/app/api/admin/settings/ebook/route.ts @@ -14,7 +14,10 @@ export async function PUT(request: NextRequest) { return requireAdmin(req, async () => { try { // Parse request body - new structure with separate source toggles - const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl } = await request.json(); + const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl, autoGrabEnabled } = await request.json(); + + // Enforce: auto-grab must be false if no sources are enabled + const effectiveAutoGrabEnabled = (annasArchiveEnabled || indexerSearchEnabled) ? (autoGrabEnabled ?? true) : false; // Validate format const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any']; @@ -66,6 +69,12 @@ export async function PUT(request: NextRequest) { category: 'ebook', description: 'Preferred e-book format', }, + { + key: 'ebook_auto_grab_enabled', + value: effectiveAutoGrabEnabled ? 'true' : 'false', + category: 'ebook', + description: 'Automatically create ebook requests after audiobook downloads complete', + }, // Anna's Archive specific settings { key: 'ebook_sidecar_base_url', diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 9158889..1b020be 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -139,6 +139,8 @@ export async function GET(request: NextRequest) { flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '', // General settings preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub', + // Auto-grab: default true to preserve existing behavior + autoGrabEnabled: configMap.get('ebook_auto_grab_enabled') !== 'false', }, general: { appName: configMap.get('app_name') || 'ReadMeABook', diff --git a/src/app/api/requests/[id]/interactive-search-ebook/route.ts b/src/app/api/requests/[id]/interactive-search-ebook/route.ts new file mode 100644 index 0000000..6dc586b --- /dev/null +++ b/src/app/api/requests/[id]/interactive-search-ebook/route.ts @@ -0,0 +1,430 @@ +/** + * Component: Interactive Search Ebook API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Searches for ebooks from multiple sources (Anna's Archive + Indexers) + * Returns combined results for user selection in interactive modal + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getConfigService } from '@/lib/services/config.service'; +import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; +import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm'; +import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; +import { RMABLogger } from '@/lib/utils/logger'; +import { + searchByAsin, + searchByTitle, + getSlowDownloadLinks, +} from '@/lib/services/ebook-scraper'; + +const logger = RMABLogger.create('API.InteractiveSearchEbook'); + +// Unified result type for frontend +export interface EbookSearchResult { + // Common fields (match RankedTorrent shape for UI compatibility) + guid: string; + title: string; + size: number; + seeders?: number; + indexer: string; + indexerId?: number; + publishDate: Date; + downloadUrl: string; + infoUrl?: string; + protocol?: string; // 'torrent' or 'usenet' - determines download client + + // Ranking fields + score: number; + finalScore: number; + bonusPoints: number; + bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>; + rank: number; + breakdown: { + formatScore: number; + sizeScore: number; + seederScore: number; + matchScore: number; + totalScore: number; + notes: string[]; + }; + + // Ebook-specific fields + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id: parentRequestId } = await params; + const body = await request.json().catch(() => ({})); + const customTitle = body.customTitle as string | undefined; + + // Get the parent audiobook request + const parentRequest = await prisma.request.findUnique({ + where: { id: parentRequestId }, + include: { audiobook: true }, + }); + + if (!parentRequest) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + if (parentRequest.type !== 'audiobook') { + return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 }); + } + + if (!['downloaded', 'available'].includes(parentRequest.status)) { + return NextResponse.json( + { error: `Cannot search ebooks for request in ${parentRequest.status} status` }, + { status: 400 } + ); + } + + // Check for existing non-retryable ebook request + const existingEbookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, + }); + + if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${existingEbookRequest.status})`, + existingRequestId: existingEbookRequest.id, + }, { status: 400 }); + } + + // Get ebook configuration + const configService = getConfigService(); + const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([ + configService.get('ebook_annas_archive_enabled'), + configService.get('ebook_indexer_search_enabled'), + configService.get('ebook_sidecar_preferred_format'), + configService.get('ebook_sidecar_base_url'), + configService.get('ebook_sidecar_flaresolverr_url'), + ]); + + const isAnnasArchiveEnabled = annasArchiveEnabled === 'true'; + const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; + const format = preferredFormat || 'epub'; + const annasBaseUrl = baseUrl || 'https://annas-archive.li'; + + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { + return NextResponse.json( + { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, + { status: 400 } + ); + } + + const audiobook = parentRequest.audiobook; + const searchTitle = customTitle || audiobook.title; + + logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`); + logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`); + + // Search both sources in parallel + const searchPromises: Promise[] = []; + + if (isAnnasArchiveEnabled) { + searchPromises.push( + searchAnnasArchiveForInteractive( + audiobook.audibleAsin || undefined, + searchTitle, + audiobook.author, + format, + annasBaseUrl, + flaresolverrUrl || undefined + ).catch((err) => { + logger.error(`Anna's Archive search failed: ${err.message}`); + return null; + }) + ); + } + + if (isIndexerSearchEnabled) { + searchPromises.push( + searchIndexersForInteractive( + searchTitle, + audiobook.author, + format + ).catch((err) => { + logger.error(`Indexer search failed: ${err.message}`); + return null; + }) + ); + } + + const searchResults = await Promise.all(searchPromises); + + // Combine results: Anna's Archive first (if found), then ranked indexer results + const combinedResults: EbookSearchResult[] = []; + let rank = 1; + + // Add Anna's Archive result first (if enabled and found) + if (isAnnasArchiveEnabled && searchResults[0]) { + const annasResults = searchResults[0]; + for (const result of annasResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + // Add indexer results (already ranked) + const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0; + if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) { + const indexerResults = searchResults[indexerResultsIndex]; + for (const result of indexerResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + logger.info(`Found ${combinedResults.length} total ebook results`); + + return NextResponse.json({ + results: combinedResults, + searchTitle, + preferredFormat: format, + }); + + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * Search Anna's Archive and return normalized results + */ +async function searchAnnasArchiveForInteractive( + asin: string | undefined, + title: string, + author: string, + preferredFormat: string, + baseUrl: string, + flaresolverrUrl?: string +): Promise { + let md5: string | null = null; + let searchMethod: 'asin' | 'title' = 'title'; + + // Try ASIN search first + if (asin) { + logger.info(`Searching Anna's Archive by ASIN: ${asin}`); + md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + searchMethod = 'asin'; + logger.info(`Found via ASIN: ${md5}`); + } + } + + // Fallback to title search + if (!md5) { + logger.info(`Searching Anna's Archive by title: "${title}"`); + md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + logger.info(`Found via title: ${md5}`); + } + } + + if (!md5) { + logger.info('No results from Anna\'s Archive'); + return []; + } + + // Get download links + const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl); + + if (slowLinks.length === 0) { + logger.warn(`Found MD5 ${md5} but no download links available`); + return []; + } + + // Return as normalized result - always score 100 for Anna's Archive + const score = 100; + + return [{ + guid: `annas-archive-${md5}`, + title: `${title} - ${author}`, + size: 0, // Unknown until download + seeders: 999, // N/A for direct download, use high number for display + indexer: "Anna's Archive", + publishDate: new Date(), + downloadUrl: slowLinks[0], + infoUrl: `${baseUrl}/md5/${md5}`, + + score, + finalScore: score, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 10, + sizeScore: 15, + seederScore: 15, + matchScore: 60, + totalScore: score, + notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"], + }, + + source: 'annas_archive', + format: preferredFormat, + md5, + downloadUrls: slowLinks, + }]; +} + +/** + * Search indexers and return ranked results + */ +async function searchIndexersForInteractive( + title: string, + author: string, + preferredFormat: string +): Promise { + const configService = getConfigService(); + + // Get indexer configuration + const indexersConfigStr = await configService.get('prowlarr_indexers'); + if (!indexersConfigStr) { + logger.warn('No indexers configured'); + return []; + } + + const indexersConfig = JSON.parse(indexersConfigStr); + if (indexersConfig.length === 0) { + logger.warn('No indexers enabled'); + return []; + } + + // Build indexer priorities map + const indexerPriorities = new Map( + indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10]) + ); + + // Get flag configurations + const flagConfigStr = await configService.get('indexer_flag_config'); + const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; + + // Group indexers by ebook categories + const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + + logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`); + + // Get Prowlarr service + const prowlarr = await getProwlarrService(); + + // Search each group and combine results + const allResults = []; + + for (const group of groups) { + try { + const groupResults = await prowlarr.search(title, { + categories: group.categories, + indexerIds: group.indexerIds, + minSeeders: 0, + maxResults: 100, + }); + allResults.push(...groupResults); + } catch (error) { + logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`); + } + } + + logger.info(`Found ${allResults.length} results from indexers`); + + if (allResults.length === 0) { + return []; + } + + // Rank results with ebook scoring + // Use requireAuthor=false for interactive mode (let user decide) + const rankedResults = rankEbookTorrents(allResults, { + title, + author, + preferredFormat, + }, { + indexerPriorities, + flagConfigs, + requireAuthor: false, + }); + + // Log ranking debug info (same format as search-ebook.processor.ts) + if (rankedResults.length > 0) { + const top3 = rankedResults.slice(0, 3); + logger.info(`==================== EBOOK INTERACTIVE SEARCH DEBUG ====================`); + logger.info(`Requested Title: "${title}"`); + logger.info(`Requested Author: "${author}"`); + logger.info(`Preferred Format: ${preferredFormat}`); + logger.info(`Top ${top3.length} results (out of ${rankedResults.length} total):`); + logger.info(`--------------------------------------------------------------`); + for (let i = 0; i < top3.length; i++) { + const result = top3[i]; + const sizeMB = (result.size / (1024 * 1024)).toFixed(1); + + logger.info(`${i + 1}. "${result.title}"`); + logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`); + logger.info(` Format: ${result.ebookFormat || 'unknown'}`); + logger.info(``); + logger.info(` Base Score: ${result.score.toFixed(1)}/100`); + logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`); + logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`); + logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`); + logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`); + logger.info(``); + logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`); + if (result.bonusModifiers.length > 0) { + for (const mod of result.bonusModifiers) { + logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`); + } + } + logger.info(``); + logger.info(` Final Score: ${result.finalScore.toFixed(1)}`); + if (result.breakdown.notes.length > 0) { + logger.info(` Notes: ${result.breakdown.notes.join(', ')}`); + } + if (i < top3.length - 1) { + logger.info(`--------------------------------------------------------------`); + } + } + logger.info(`==============================================================`); + } + + // Convert to unified result type + return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({ + guid: result.guid, + title: result.title, + size: result.size, + seeders: result.seeders, + indexer: result.indexer, + indexerId: result.indexerId, + publishDate: result.publishDate, + downloadUrl: result.downloadUrl, + infoUrl: result.infoUrl, + + score: result.score, + finalScore: result.finalScore, + bonusPoints: result.bonusPoints, + bonusModifiers: result.bonusModifiers, + rank: result.rank, + breakdown: result.breakdown, + + source: 'prowlarr', + format: result.ebookFormat, + protocol: result.protocol, + })); +} diff --git a/src/app/api/requests/[id]/select-ebook/route.ts b/src/app/api/requests/[id]/select-ebook/route.ts new file mode 100644 index 0000000..7c38f22 --- /dev/null +++ b/src/app/api/requests/[id]/select-ebook/route.ts @@ -0,0 +1,258 @@ +/** + * Component: Select Ebook API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Creates an ebook request with a user-selected source (Anna's Archive or indexer) + * Routes to appropriate download processor based on source type + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getConfigService } from '@/lib/services/config.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.SelectEbook'); + +interface SelectedEbook { + guid: string; + title: string; + size: number; + seeders: number; + indexer: string; + indexerId?: number; + downloadUrl: string; + infoUrl?: string; + score: number; + finalScore: number; + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; + protocol?: string; // 'torrent' or 'usenet' - determines download client +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id: parentRequestId } = await params; + const body = await request.json(); + const selectedEbook = body.ebook as SelectedEbook; + + if (!selectedEbook) { + return NextResponse.json({ error: 'No ebook selected' }, { status: 400 }); + } + + if (!selectedEbook.source) { + return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 }); + } + + // Get the parent audiobook request + const parentRequest = await prisma.request.findUnique({ + where: { id: parentRequestId }, + include: { audiobook: true }, + }); + + if (!parentRequest) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + if (parentRequest.type !== 'audiobook') { + return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 }); + } + + if (!['downloaded', 'available'].includes(parentRequest.status)) { + return NextResponse.json( + { error: `Cannot select ebook for request in ${parentRequest.status} status` }, + { status: 400 } + ); + } + + // Check for existing ebook request + let ebookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, + }); + + if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${ebookRequest.status})`, + existingRequestId: ebookRequest.id, + }, { status: 400 }); + } + + // Create or update ebook request + if (ebookRequest) { + // Reset existing failed/pending request + ebookRequest = await prisma.request.update({ + where: { id: ebookRequest.id }, + data: { + status: 'searching', + progress: 0, + errorMessage: null, + updatedAt: new Date(), + }, + }); + logger.info(`Reusing existing ebook request ${ebookRequest.id}`); + } else { + // Create new ebook request + ebookRequest = await prisma.request.create({ + data: { + userId: parentRequest.userId, + audiobookId: parentRequest.audiobookId, + type: 'ebook', + parentRequestId, + status: 'searching', + progress: 0, + }, + }); + logger.info(`Created new ebook request ${ebookRequest.id}`); + } + + const audiobook = parentRequest.audiobook; + const jobQueue = getJobQueueService(); + + // Route to appropriate download based on source + if (selectedEbook.source === 'annas_archive') { + // Anna's Archive: Direct HTTP download + await handleAnnasArchiveDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } else { + // Indexer: Torrent/NZB download + await handleIndexerDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } + + return NextResponse.json({ + success: true, + message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`, + requestId: ebookRequest.id, + }); + + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * Handle Anna's Archive download (direct HTTP) + */ +async function handleAnnasArchiveDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + const configService = getConfigService(); + const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub'; + + logger.info(`Starting Anna's Archive download for "${audiobook.title}"`); + logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`); + + // Create download history record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: "Anna's Archive", + torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + torrentSizeBytes: null, // Unknown until download starts + qualityScore: selectedEbook.score, + selected: true, + downloadClient: 'direct', + downloadStatus: 'queued', + }, + }); + + // Store all download URLs for retry purposes + if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) { + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + torrentUrl: JSON.stringify(selectedEbook.downloadUrls), + }, + }); + } + + // Trigger direct download job + await jobQueue.addStartDirectDownloadJob( + requestId, + downloadHistory.id, + selectedEbook.downloadUrl, + `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + undefined // Size unknown + ); + + logger.info(`Queued direct download job for request ${requestId}`); +} + +/** + * Handle indexer download (torrent/NZB) + */ +async function handleIndexerDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + logger.info(`Starting indexer download for "${audiobook.title}"`); + logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`); + + // Convert to RankedTorrent shape expected by download job + // Note: format is omitted as ebook formats (epub, pdf) differ from audiobook formats (M4B, M4A, MP3) + const torrentForJob = { + guid: selectedEbook.guid, + title: selectedEbook.title, + size: selectedEbook.size, + seeders: selectedEbook.seeders || 0, + indexer: selectedEbook.indexer, + indexerId: selectedEbook.indexerId, + downloadUrl: selectedEbook.downloadUrl, + infoUrl: selectedEbook.infoUrl, + publishDate: new Date(), + score: selectedEbook.score, + finalScore: selectedEbook.finalScore, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 0, + totalScore: selectedEbook.score, + notes: [], + }, + protocol: selectedEbook.protocol, // Pass through protocol for torrent vs usenet routing + }; + + // Use the download job (same as audiobooks) + await jobQueue.addDownloadJob(requestId, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + }, torrentForJob as any); // Cast to any since ebook torrents don't have audiobook format field + + logger.info(`Queued download job for request ${requestId}`); +} diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index ea69d5f..9ee5ff9 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -1,6 +1,10 @@ /** * Component: Interactive Torrent Search Modal * Documentation: documentation/phase3/prowlarr.md + * + * Supports two search modes: + * - audiobook: Search for audiobook torrents/NZBs (default) + * - ebook: Search for ebooks from Anna's Archive + indexers */ 'use client'; @@ -10,7 +14,14 @@ import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; -import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests'; +import { + useInteractiveSearch, + useSelectTorrent, + useSearchTorrents, + useRequestWithTorrent, + useInteractiveSearchEbook, + useSelectEbook, +} from '@/lib/hooks/useRequests'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; interface InteractiveTorrentSearchModalProps { @@ -23,6 +34,7 @@ interface InteractiveTorrentSearchModalProps { }; fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; + searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook } export function InteractiveTorrentSearchModal({ @@ -32,8 +44,9 @@ export function InteractiveTorrentSearchModal({ audiobook, fullAudiobook, onSuccess, + searchMode = 'audiobook', }: InteractiveTorrentSearchModalProps) { - // Hooks for existing request flow + // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent(); @@ -41,17 +54,30 @@ export function InteractiveTorrentSearchModal({ const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); - const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]); + // Hooks for ebook flow + const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook(); + const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook(); + + const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); const [searchTitle, setSearchTitle] = useState(audiobook.title); // Determine which mode we're in + const isEbookMode = searchMode === 'ebook'; const hasRequestId = !!requestId; - const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook; - const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent; - const error = hasRequestId - ? (searchByRequestError || selectTorrentError) - : (searchByAudiobookError || requestWithTorrentError); + + // Loading/error state based on mode + const isSearching = isEbookMode + ? isSearchingEbooks + : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); + const isDownloading = isEbookMode + ? isSelectingEbook + : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); + const error = isEbookMode + ? (searchEbooksError || selectEbookError) + : (hasRequestId + ? (searchByRequestError || selectTorrentError) + : (searchByAudiobookError || requestWithTorrentError)); // Reset search title when modal opens/closes or audiobook changes React.useEffect(() => { @@ -72,12 +98,20 @@ export function InteractiveTorrentSearchModal({ try { let data; - if (hasRequestId) { - // Existing flow: search by requestId with optional custom title + if (isEbookMode) { + // Ebook mode: search Anna's Archive + indexers + if (!requestId) { + console.error('Ebook search requires a requestId'); + return; + } + const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; + data = await searchEbooks(requestId, customTitle); + } else if (hasRequestId) { + // Existing audiobook flow: search by requestId with optional custom title const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { - // New flow: search by custom title + original author + optional ASIN for size scoring + // New audiobook flow: search by custom title + original author + optional ASIN for size scoring const asin = fullAudiobook?.asin; data = await searchByAudiobook(searchTitle, audiobook.author, asin); } @@ -102,11 +136,17 @@ export function InteractiveTorrentSearchModal({ if (!confirmTorrent) return; try { - if (hasRequestId) { - // Existing flow: select torrent for existing request + if (isEbookMode) { + // Ebook flow: select ebook for existing audiobook request + if (!requestId) { + throw new Error('Request ID required for ebook selection'); + } + await selectEbook(requestId, confirmTorrent); + } else if (hasRequestId) { + // Existing audiobook flow: select torrent for existing request await selectTorrent(requestId, confirmTorrent); } else { - // New flow: create request with torrent + // New audiobook flow: create request with torrent if (!fullAudiobook) { throw new Error('Audiobook data required to create request'); } @@ -120,7 +160,7 @@ export function InteractiveTorrentSearchModal({ // Request list will auto-refresh via SWR } catch (err) { // Error already handled by hook - console.error('Failed to download torrent:', err); + console.error('Failed to download:', err); setConfirmTorrent(null); } }; @@ -138,14 +178,26 @@ export function InteractiveTorrentSearchModal({ return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; }; + // UI text based on mode + const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent'; + const searchLabel = isEbookMode ? 'Search Title' : 'Search Title'; + const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...'; + const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...'; + const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found'; + const resultCountText = (count: number) => + isEbookMode + ? `Found ${count} ebook${count !== 1 ? 's' : ''}` + : `Found ${count} torrent${count !== 1 ? 's' : ''}`; + const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent'; + return ( <> - +
{/* Search customization - editable for ALL modes */}
setSearchTitle(e.target.value)} onKeyPress={handleSearchKeyPress} - placeholder="Enter book title to search..." + placeholder={searchPlaceholder} disabled={isSearching} className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" /> @@ -180,14 +232,14 @@ export function InteractiveTorrentSearchModal({ {isSearching && (
- Searching for torrents... + {loadingText}
)} {/* No results */} {!isSearching && results.length === 0 && (
-

No torrents/nzbs found

+

{noResultsText}

@@ -220,7 +272,7 @@ export function InteractiveTorrentSearchModal({ Seeds - Indexer + {isEbookMode ? 'Source' : 'Indexer'} Action @@ -246,21 +298,30 @@ export function InteractiveTorrentSearchModal({
+ {/* Anna's Archive badge for ebook mode */} + {isEbookMode && result.source === 'annas_archive' && ( + + Anna's Archive + + )} {result.format && ( - + {result.format} )} - {formatSize(result.size)} - - - {result.seeders} seeds + {result.size > 0 ? formatSize(result.size) : 'Unknown'} + {/* Hide seeds badge for Anna's Archive results */} + {!(isEbookMode && result.source === 'annas_archive') && ( + + {result.seeders} seeds + + )}
- {formatSize(result.size)} + {result.size > 0 ? formatSize(result.size) : '—'} @@ -271,15 +332,23 @@ export function InteractiveTorrentSearchModal({ {result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'} - - - - - {result.seeders} - + {isEbookMode && result.source === 'annas_archive' ? ( + N/A + ) : ( + + + + + {result.seeders} + + )} - {result.indexer} + {isEbookMode && result.source === 'annas_archive' ? ( + Anna's Archive + ) : ( + result.indexer + )}